diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index d1a4df52..579eddb6 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -178,49 +178,62 @@ public static class CultDocumentMessagePackSerialization } /// - /// Reads a store file: exactly one complete MessagePack array. A truncated file, bytes after the array, a value that is not - /// an array, or a first slot that is not a header string are refused, so a reader never takes part of a file for the whole. - /// The rewriting paths use this same reader as their verdict on whether a file may be replaced. + /// Reads a store file: exactly one complete MessagePack array of three slots (header, schema catalog, records), or an empty + /// array, which is an empty store. A truncated file, bytes after the array, a value that is not an array, a first slot that + /// is not a header string, a store with fewer or more slots, or a body that does not decode are refused with + /// , so a reader never takes part of a file for the whole and never skips a slot + /// it does not understand. The rewriting paths use this same reader as their verdict on whether a file may be replaced. /// - public static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload) + public static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload) => ReadStore(payload, ReadSchemaCatalogEntry); + + internal delegate CultSchemaCatalogEntry CatalogEntryReader(ref MessagePackReader reader); + + // The one store reader. A caller with an older catalog layout supplies its own entry reader; the framing, the slot count + // and the records are judged here, once. + internal static CultPersistedStoreSnapshot ReadStore(byte[] payload, CatalogEntryReader readCatalogEntry) { - RequireOneArray(payload); - var reader = new MessagePackReader(payload); - var fieldCount = reader.ReadArrayHeader(); - var snapshot = new CultPersistedStoreSnapshot(); - - if (fieldCount > 0) + try { + RequireOneArray(payload); + var reader = new MessagePackReader(payload); + var fieldCount = reader.ReadArrayHeader(); + if (fieldCount != 0 && fieldCount != StoreSnapshotFieldCount) + { + throw new CultStoreUnreadableException( + $"The store has {fieldCount} top-level slots; this runtime reads {StoreSnapshotFieldCount} (header, schema catalog, records) or none."); + } + + var snapshot = new CultPersistedStoreSnapshot(); + if (fieldCount == 0) + return snapshot; + snapshot.FormatVersion = reader.ReadString() - ?? throw new NotSupportedException("Store snapshot declares no format version; this runtime reads " + CultPersistedStoreSnapshot.FormatV1 + ", " + CultPersistedStoreSnapshot.FormatV2 + " and " + CultPersistedStoreSnapshot.FormatV3 + "."); - } + ?? throw new CultStoreUnreadableException("Store snapshot declares no format version; this runtime reads " + CultPersistedStoreSnapshot.FormatV1 + ", " + CultPersistedStoreSnapshot.FormatV2 + " and " + CultPersistedStoreSnapshot.FormatV3 + "."); - if (fieldCount > 1) - { var catalogCount = reader.ReadArrayHeader(); snapshot.SchemaCatalog = new CultSchemaCatalogEntry[catalogCount]; for (var index = 0; index < catalogCount; index++) { - snapshot.SchemaCatalog[index] = ReadSchemaCatalogEntry(ref reader); + snapshot.SchemaCatalog[index] = readCatalogEntry(ref reader); } - } - if (fieldCount > 2) - { var recordCount = reader.ReadArrayHeader(); snapshot.Records = new CultPersistedRecord[recordCount]; for (var index = 0; index < recordCount; index++) { snapshot.Records[index] = ReadPersistedRecord(ref reader); } - } - for (var index = StoreSnapshotFieldCount; index < fieldCount; index++) + return snapshot; + } + catch (CultStoreUnreadableException) { - reader.Skip(); + throw; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + throw new CultStoreUnreadableException(ex.Message, ex); } - - return snapshot; } private static void RequireOneArray(byte[] bytes) @@ -229,16 +242,16 @@ public static class CultDocumentMessagePackSerialization try { if (whole.NextMessagePackType != MessagePackType.Array) - throw new NotSupportedException($"The store is a MessagePack {whole.NextMessagePackType}, not an array."); + throw new CultStoreUnreadableException($"The store is a MessagePack {whole.NextMessagePackType}, not an array."); whole.Skip(); } catch (Exception ex) when (ex is EndOfStreamException or MessagePackSerializationException or InvalidOperationException) { - throw new NotSupportedException("The store is not one complete MessagePack array.", ex); + throw new CultStoreUnreadableException("The store is not one complete MessagePack array.", ex); } if (!whole.End) - throw new NotSupportedException("The store has bytes after its MessagePack array."); + throw new CultStoreUnreadableException("The store has bytes after its MessagePack array."); } /// Refuses a single-file snapshot this runtime cannot read, naming the format or record it found. @@ -249,7 +262,7 @@ public static class CultDocumentMessagePackSerialization !string.Equals(version, CultPersistedStoreSnapshot.FormatV2, StringComparison.Ordinal) && !string.Equals(version, CultPersistedStoreSnapshot.FormatV3, StringComparison.Ordinal)) { - throw new NotSupportedException( + throw new CultStoreUnreadableException( $"Store format {version} is not readable; this runtime reads {CultPersistedStoreSnapshot.FormatV1}, {CultPersistedStoreSnapshot.FormatV2} and {CultPersistedStoreSnapshot.FormatV3}."); } @@ -259,7 +272,7 @@ public static class CultDocumentMessagePackSerialization var variant = snapshot.Records.FirstOrDefault(record => record.Variant != null); if (variant != null) { - throw new NotSupportedException( + throw new CultStoreUnreadableException( $"Record '{variant.Key}' (schema '{variant.SchemaId}') is a variant but the store declares {version}; variants need {CultPersistedStoreSnapshot.FormatV2} or {CultPersistedStoreSnapshot.FormatV3}."); } } diff --git a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj index c2588e24..b81dcf92 100644 --- a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj +++ b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj @@ -11,5 +11,6 @@ + diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index d887e44e..31775962 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -230,14 +230,20 @@ rest, so a file already declared `v3` stays `v3` when it merges onto it. A direc store's manifest says `cultcache.store.v5.directory-content-addressed-pages` under the same rule, and `v4` otherwise. -A single-file store is exactly one MessagePack array. Every runtime refuses a file that is -truncated, has bytes after the array (a second store appended included), is not an array, or -whose first slot is not a header it reads; it refuses these on open and on rewrite alike. A -zero-byte file is an empty store. A flush or a commit replaces a file only when the +A single-file store is exactly one MessagePack array of three slots: the header, the schema +catalog and the records. Every runtime refuses a file that is truncated, has bytes after the +array (a second store appended included), is not an array, has fewer or more than three slots +(a header alone included), whose first slot is not a header it reads, or whose catalog or +records it cannot decode (a record with more slots than its header allows, a variant record +it does not read). A runtime never skips a slot it does not understand. It refuses on open +and on rewrite alike, because the rewrite asks the same full reader as the open. A zero-byte +file and an empty array are an empty store. A flush or a commit replaces a file only when the runtime's own reader opens it, so one verdict covers open, flush and commit, and a file the -runtime cannot read is left as it is. Rust, TypeScript and Python also open the legacy -envelope array (an array of `key`/`type`/`payload`/`storedAt` maps) and replace it; C# has -no legacy reader and refuses it. The shared byte vectors and each runtime's verdict are +runtime cannot read is left as it is. Each runtime refuses with one typed error: +`CultStoreUnreadableException` (C#), `StoreUnreadableError` (Rust, TypeScript, Python), with +the underlying cause attached. Rust, TypeScript and Python also open the legacy envelope +array (an array of `key`/`type`/`payload`/`storedAt` maps) and replace it; C# has no legacy +reader and refuses it. The shared byte vectors and each runtime's verdict are `tests/vectors/document-variants-c2a/readability/manifest.txt`. ## Variant Records diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index e12ee0b9..a693d36c 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -3419,8 +3419,9 @@ namespace GameCult.Caching /// /// This store's one reader of a non-empty store file, and so its one verdict on whether the file may be replaced: open, - /// flush and commit all ask it. It must throw for anything that is not exactly one store this runtime reads (a - /// truncated file, bytes after the store, a format it does not know), so a rewrite never overwrites a file it cannot see. + /// flush and commit all ask it. It must throw for anything that is not exactly one + /// store this runtime reads (a truncated file, bytes after the store, a missing or extra slot, a format or record it does + /// not know), so a rewrite never overwrites a file it cannot see. /// protected abstract CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data); diff --git a/src/GameCult.Caching/CultStoreUnreadableException.cs b/src/GameCult.Caching/CultStoreUnreadableException.cs new file mode 100644 index 00000000..cb18ca3b --- /dev/null +++ b/src/GameCult.Caching/CultStoreUnreadableException.cs @@ -0,0 +1,18 @@ +using System; + +namespace GameCult.Caching +{ + /// + /// A store file this runtime cannot read: not exactly one complete store (truncated, bytes after it, a value that is not a + /// store, a missing or extra top-level slot), a format or record it does not know, or a body it cannot decode. Open, flush, + /// commit and CultMesh's single-file reads all refuse a file with this one exception, and a refused file is left as it was. + /// The underlying cause, where there is one, is the inner exception. + /// + public sealed class CultStoreUnreadableException : NotSupportedException + { + public CultStoreUnreadableException(string message, Exception? innerException = null) + : base(message, innerException) + { + } + } +} diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs index 2a2ff2f6..8b6b8a58 100644 --- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs +++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs @@ -270,6 +270,8 @@ namespace GameCult.Mesh WriteFileAtomically(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); } + // Every refusal is a CultStoreUnreadableException. A store whose catalog entries are in the older layout is read by the + // same store reader with the older entry reader, so it is judged by the same framing, slot count and records. private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path) { var bytes = File.ReadAllBytes(path); @@ -280,46 +282,15 @@ namespace GameCult.Mesh { snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); } - catch (Exception ex) when (ex is MessagePackSerializationException or InvalidOperationException) + catch (CultStoreUnreadableException) { - snapshot = ReadLegacySingleFileSnapshot(bytes); + snapshot = CultDocumentMessagePackSerialization.ReadStore(bytes, ReadLegacySchemaCatalogEntry); } CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot); return snapshot; } - private static CultPersistedStoreSnapshot ReadLegacySingleFileSnapshot(byte[] bytes) - { - var reader = new MessagePackReader(bytes); - var fieldCount = reader.ReadArrayHeader(); - var snapshot = new CultPersistedStoreSnapshot(); - if (fieldCount > 0) - snapshot.FormatVersion = reader.ReadString() - ?? throw new NotSupportedException("Store snapshot declares no format version."); - - if (fieldCount > 1) - { - var catalogCount = reader.ReadArrayHeader(); - snapshot.SchemaCatalog = new CultSchemaCatalogEntry[catalogCount]; - for (var index = 0; index < catalogCount; index++) - snapshot.SchemaCatalog[index] = ReadLegacySchemaCatalogEntry(ref reader); - } - - if (fieldCount > 2) - { - var recordCount = reader.ReadArrayHeader(); - snapshot.Records = new CultPersistedRecord[recordCount]; - for (var index = 0; index < recordCount; index++) - snapshot.Records[index] = CultDocumentMessagePackSerialization.ReadPersistedRecord(ref reader); - } - - for (var index = 3; index < fieldCount; index++) - reader.Skip(); - - return snapshot; - } - private static CultSchemaCatalogEntry ReadLegacySchemaCatalogEntry(ref MessagePackReader reader) { var fieldCount = reader.ReadArrayHeader(); diff --git a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs index 181eca56..98a8be67 100644 --- a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs +++ b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs @@ -108,7 +108,7 @@ namespace GameCult.Caching.Tests using var cache = new CultCache(); var error = Assert.Catch(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(file))); Assert.That(error, Is.Not.Null); - Assert.That(error!.ToString(), Does.Contain(nameof(NotSupportedException))); + Assert.That(error, Is.TypeOf()); return error.ToString(); } finally @@ -164,7 +164,7 @@ namespace GameCult.Caching.Tests var bytes = new byte[] { 0x93, 0xc0, 0x90, 0x90 }; Assert.That(() => CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes), - Throws.TypeOf().With.Message.Contains("format version")); + Throws.TypeOf().With.Message.Contains("format version")); } [Test] diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs index 535390e1..7abee9a5 100644 --- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs +++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs @@ -76,7 +76,7 @@ namespace GameCult.Caching.Tests if (reads) Assert.DoesNotThrow(Open); else - Assert.That(Open, Throws.Exception); + Assert.That(Open, Throws.TypeOf()); } [TestCaseSource(nameof(Vectors))] @@ -91,11 +91,12 @@ namespace GameCult.Caching.Tests if (reads) { store.PushAll(); - Assert.That(Header(path), Does.StartWith("cultcache.store.v"), "the file is a store this runtime wrote"); + // A whole-store flush writes what the store holds: one deck with no element ids is v1, whatever the file was. + Assert.That(Header(path), Is.EqualTo("cultcache.store.v1")); return; } - Assert.That(() => store.PushAll(), Throws.Exception); + Assert.That(() => store.PushAll(), Throws.TypeOf()); Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten"); } @@ -110,14 +111,50 @@ namespace GameCult.Caching.Tests if (reads) { cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))); - Assert.That(Header(path), Does.StartWith("cultcache.store.v"), "the file is a store this runtime wrote"); + Assert.That(Header(path), Is.EqualTo("cultcache.store.v1"), "an unconditional commit writes the store's whole view, which holds no element id"); return; } - Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.Exception); + Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf()); Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten"); } + // A commit that lands onto the file as it is keeps the header the file carries: a store already v3 stays v3. + [TestCase("../v3-base.msgpack", "cultcache.store.v3")] + [TestCase("zero-byte.bin", "cultcache.store.v1")] + [TestCase("empty-array.bin", "cultcache.store.v1")] + public void AConditionalCommitOntoAReadableFileKeepsItsHeader(string vector, string header) + { + var path = Seed("conditional.cc"); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), vector))); + + Assert.That(cache.Commit(batch => + { + batch.Expect(new CultRecordKey("e"), null); + batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e")); + }), Is.True); + Assert.That(Header(path), Is.EqualTo(header)); + } + + // Every runtime's tests walk the manifest, so a vector without a row would go untested. + [Test] + public void EveryVectorInTheFolderHasAManifestRow() + { + var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal); + var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal); + Assert.That(rows, Is.EqualTo(files)); + } + + // The refusal carries what the reader choked on. + [Test] + public void ARefusalKeepsItsCauseAsTheInnerException() + { + var error = Assert.Throws(() => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(Path.Combine(VectorRoot(), "truncated.bin"))))!; + Assert.That(error.InnerException, Is.Not.Null); + } + // The record type of the shared valid store (v3-base.msgpack: alpha and beta), so a cache can open it. [CultDocument("vectors.item", "vectors.item.v1")] [MessagePackObject] diff --git a/tests/GameCult.Caching.Tests/VariantTests.cs b/tests/GameCult.Caching.Tests/VariantTests.cs index c8d7a8df..7567c640 100644 --- a/tests/GameCult.Caching.Tests/VariantTests.cs +++ b/tests/GameCult.Caching.Tests/VariantTests.cs @@ -1294,7 +1294,7 @@ namespace GameCult.Caching.Tests public void AVariantRecordUnderAV1HeaderIsRefusedNamingTheRecord() { var path = WriteStore(CultPersistedStoreSnapshot.FormatV1, PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base")); - var error = Assert.Throws(() => Open(path).Dispose())!; + var error = Assert.Throws(() => Open(path).Dispose())!; Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("cultcache.store.v2")); } @@ -1302,7 +1302,7 @@ namespace GameCult.Caching.Tests public void AVariantRecordWithAPayloadIsRefusedNamingTheRecord() { var path = WriteStore(PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base", payload: new byte[] { 0x90 })); - var error = Assert.Throws(() => Open(path).Dispose())!; + var error = Assert.Throws(() => Open(path).Dispose())!; Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("payload")); } @@ -1310,7 +1310,7 @@ namespace GameCult.Caching.Tests public void AnOverrideWithAnUnknownOpIsRefusedNamingTheRecord() { var path = WriteStore(PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base", op: (CultOverrideOp)7)); - var error = Assert.Throws(() => Open(path).Dispose())!; + var error = Assert.Throws(() => Open(path).Dispose())!; Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("op 7")); } diff --git a/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs index 1831ce28..e3566b54 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs @@ -77,8 +77,16 @@ public sealed class CultMeshSingleFileReadabilityTests continue; } - Assert.That(() => write(path), Throws.Exception, name); + Assert.That(() => write(path), Throws.TypeOf(), name); File.ReadAllBytes(path).Should().Equal(bytes, name + " rewrote a file this runtime cannot read"); } } + + [Test] + public void EveryVectorInTheFolderHasAManifestRow() + { + var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal); + var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal); + rows.Should().Equal(files); + } } diff --git a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs index 2521aa8a..58ee9a2e 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs @@ -23,8 +23,8 @@ public sealed class CultMeshVariantRefusalTests throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}."); } - private static NotSupportedException Refusal(string vector) => - Assert.Throws(() => + private static CultStoreUnreadableException Refusal(string vector) => + Assert.Throws(() => CultMesh.ReadSingleFileDocumentPayload(VectorPath(vector), new CultRecordKey("item:bellows"), ItemSchemaId))!; [Test] @@ -45,6 +45,17 @@ public sealed class CultMeshVariantRefusalTests message.Should().Contain("item:anvil").And.Contain(ItemSchemaId); } + // The older catalog layout is read by the same store reader, so it is held to one array too: the same store with bytes after it + // is refused, and only the bytes make the difference. + [Test] + public void ReadSingleFileDocument_ReadsTheLegacyCatalogLayoutButNotWithBytesAfterIt() + { + CultMesh.ReadSingleFileDocumentPayload(VectorPath("legacy-catalog-plain.msgpack"), new CultRecordKey("item:bellows"), ItemSchemaId) + .Should().NotBeEmpty(); + + Refusal("legacy-catalog-trailing.msgpack").Message.Should().Contain("after its MessagePack array"); + } + // Q6: CultMesh does not resolve variants. It refuses a variant key naming it and its base, and still reads the plain // records of the same v2 store. [Test] diff --git a/tests/vectors/document-variants-c0/generate.py b/tests/vectors/document-variants-c0/generate.py index c6e5d2da..b649475f 100644 --- a/tests/vectors/document-variants-c0/generate.py +++ b/tests/vectors/document-variants-c0/generate.py @@ -8,6 +8,10 @@ at test time. Run: python generate.py (needs 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 + legacy-catalog-plain.msgpack v1 header, the older-layout catalog entry, the plain v1 records: a store CultMesh's + legacy reader accepts + legacy-catalog-trailing.msgpack legacy-catalog-plain plus three bytes after the array: one array is + required of the legacy layout too 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) @@ -41,3 +45,6 @@ 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", []]]]]) +write("legacy-catalog-plain.msgpack", [header, [legacy_entry], records]) +(here / "legacy-catalog-trailing.msgpack").write_bytes( + msgpack.packb([header, [legacy_entry], records], use_bin_type=True) + b"\x01\x02\x03") diff --git a/tests/vectors/document-variants-c0/legacy-catalog-plain.msgpack b/tests/vectors/document-variants-c0/legacy-catalog-plain.msgpack new file mode 100644 index 00000000..6759a9b5 --- /dev/null +++ b/tests/vectors/document-variants-c0/legacy-catalog-plain.msgpack @@ -0,0 +1,2 @@ +“²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ÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4114746+00:00Ä +’§bellows*”«note:originÙGsha256:670deacd06aedb90235e6bd873674c10467a05b4adcd62dbc019279efcbcbb11Ù!2026-09-13T15:14:36.4120244+00:00Ä‘´written before cut 2 \ No newline at end of file diff --git a/tests/vectors/document-variants-c0/legacy-catalog-trailing.msgpack b/tests/vectors/document-variants-c0/legacy-catalog-trailing.msgpack new file mode 100644 index 00000000..8c300126 --- /dev/null +++ b/tests/vectors/document-variants-c0/legacy-catalog-trailing.msgpack @@ -0,0 +1,2 @@ +“²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ÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4114746+00:00Ä +’§bellows*”«note:originÙGsha256:670deacd06aedb90235e6bd873674c10467a05b4adcd62dbc019279efcbcbb11Ù!2026-09-13T15:14:36.4120244+00:00Ä‘´written before cut 2 \ No newline at end of file diff --git a/tests/vectors/document-variants-c2a/readability/bad-body-v1.bin b/tests/vectors/document-variants-c2a/readability/bad-body-v1.bin new file mode 100644 index 00000000..da41bc1e --- /dev/null +++ b/tests/vectors/document-variants-c2a/readability/bad-body-v1.bin @@ -0,0 +1 @@ +“²cultcache.store.v1 \ No newline at end of file diff --git a/tests/vectors/document-variants-c2a/readability/bad-body-v3.bin b/tests/vectors/document-variants-c2a/readability/bad-body-v3.bin new file mode 100644 index 00000000..591535f4 --- /dev/null +++ b/tests/vectors/document-variants-c2a/readability/bad-body-v3.bin @@ -0,0 +1 @@ +“²cultcache.store.v3 \ No newline at end of file diff --git a/tests/vectors/document-variants-c2a/readability/extra-top-slot.bin b/tests/vectors/document-variants-c2a/readability/extra-top-slot.bin new file mode 100644 index 00000000..1c7dbbef Binary files /dev/null and b/tests/vectors/document-variants-c2a/readability/extra-top-slot.bin differ diff --git a/tests/vectors/document-variants-c2a/readability/generate.py b/tests/vectors/document-variants-c2a/readability/generate.py index 288862ad..7680a886 100644 --- a/tests/vectors/document-variants-c2a/readability/generate.py +++ b/tests/vectors/document-variants-c2a/readability/generate.py @@ -11,6 +11,7 @@ import msgpack here = pathlib.Path(__file__).resolve().parent whole = (here.parent / "v3-base.msgpack").read_bytes() pack = lambda value: msgpack.packb(value, use_bin_type=True) +base = msgpack.unpackb(whole, raw=False) vectors = { "zero-byte": b"", @@ -28,8 +29,20 @@ vectors = { "truncated": whole[:-1], "trailing-bytes": whole + b"\x01\x02\x03", "trailing-store": whole + whole, + # A header and a body of the wrong shape, for both readable headers. + "bad-body-v1": pack(["cultcache.store.v1", 5, 6]), + "bad-body-v3": pack(["cultcache.store.v3", 5, 6]), + # A header and nothing else: not a store, whatever the header says. + "header-only-v1": pack(["cultcache.store.v1"]), + "header-only-v3": pack(["cultcache.store.v3"]), + # v3-base with its schema catalog emptied: every record names a schema the store does not publish. + "missing-schema-v3": pack([base[0], [], base[2]]), + # v3-base plus a variant of alpha: a fifth record slot, [baseKey, overrides[]], over an empty payload. Only C# reads it. + "variant-slot-v3": pack([base[0], base[1], base[2] + [["gamma"] + base[2][0][1:3] + [b"", ["alpha", []]]]]), + # v3-base with a fourth top-level slot after the records. + "extra-top-slot": pack(base + [0]), } -vectors["legacy-trailing"] = vectors["legacy-envelopes"] + b"" +vectors["legacy-trailing"] = vectors["legacy-envelopes"] + b"\x01\x02\x03" for name, data in vectors.items(): (here / f"{name}.bin").write_bytes(data) print(len(vectors), "vectors") diff --git a/tests/vectors/document-variants-c2a/readability/header-only-v1.bin b/tests/vectors/document-variants-c2a/readability/header-only-v1.bin new file mode 100644 index 00000000..29dc02fa --- /dev/null +++ b/tests/vectors/document-variants-c2a/readability/header-only-v1.bin @@ -0,0 +1 @@ +‘²cultcache.store.v1 \ No newline at end of file diff --git a/tests/vectors/document-variants-c2a/readability/header-only-v3.bin b/tests/vectors/document-variants-c2a/readability/header-only-v3.bin new file mode 100644 index 00000000..17e7a16d --- /dev/null +++ b/tests/vectors/document-variants-c2a/readability/header-only-v3.bin @@ -0,0 +1 @@ +‘²cultcache.store.v3 \ No newline at end of file diff --git a/tests/vectors/document-variants-c2a/readability/manifest.txt b/tests/vectors/document-variants-c2a/readability/manifest.txt index 8675d6b9..626d8d7b 100644 --- a/tests/vectors/document-variants-c2a/readability/manifest.txt +++ b/tests/vectors/document-variants-c2a/readability/manifest.txt @@ -15,3 +15,10 @@ map.bin refuses refuses refuses refuses truncated.bin refuses refuses refuses refuses trailing-bytes.bin refuses refuses refuses refuses trailing-store.bin refuses refuses refuses refuses +bad-body-v1.bin refuses refuses refuses refuses +bad-body-v3.bin refuses refuses refuses refuses +header-only-v1.bin refuses refuses refuses refuses +header-only-v3.bin refuses refuses refuses refuses +extra-top-slot.bin refuses refuses refuses refuses +missing-schema-v3.bin reads refuses refuses refuses +variant-slot-v3.bin reads refuses refuses refuses diff --git a/tests/vectors/document-variants-c2a/readability/missing-schema-v3.bin b/tests/vectors/document-variants-c2a/readability/missing-schema-v3.bin new file mode 100644 index 00000000..5169c46f --- /dev/null +++ b/tests/vectors/document-variants-c2a/readability/missing-schema-v3.bin @@ -0,0 +1 @@ +“²cultcache.store.v3�’”¥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-c2a/readability/variant-slot-v3.bin b/tests/vectors/document-variants-c2a/readability/variant-slot-v3.bin new file mode 100644 index 00000000..f2f09047 Binary files /dev/null and b/tests/vectors/document-variants-c2a/readability/variant-slot-v3.bin differ