Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array. Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store with and without bytes after it for CultMesh. The persistence contract says what is true. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e18624f72b
commit
436eed73e4
23 changed files with 186 additions and 84 deletions
|
|
@ -178,49 +178,62 @@ public static class CultDocumentMessagePackSerialization
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="CultStoreUnreadableException"/>, 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.
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>Refuses a single-file snapshot this runtime cannot read, naming the format or record it found.</summary>
|
||||
|
|
@ -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}.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@
|
|||
<PackageReference Include="System.Text.Json" Version="9.0.10" />
|
||||
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
|
||||
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
|
||||
<InternalsVisibleTo Include="GameCult.Mesh" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3419,8 +3419,9 @@ namespace GameCult.Caching
|
|||
|
||||
/// <summary>
|
||||
/// 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 <see cref="CultStoreUnreadableException"/> 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.
|
||||
/// </summary>
|
||||
protected abstract CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data);
|
||||
|
||||
|
|
|
|||
18
src/GameCult.Caching/CultStoreUnreadableException.cs
Normal file
18
src/GameCult.Caching/CultStoreUnreadableException.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using System;
|
||||
|
||||
namespace GameCult.Caching
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class CultStoreUnreadableException : NotSupportedException
|
||||
{
|
||||
public CultStoreUnreadableException(string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<CultStoreUnreadableException>());
|
||||
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<NotSupportedException>().With.Message.Contains("format version"));
|
||||
Throws.TypeOf<CultStoreUnreadableException>().With.Message.Contains("format version"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ namespace GameCult.Caching.Tests
|
|||
if (reads)
|
||||
Assert.DoesNotThrow(Open);
|
||||
else
|
||||
Assert.That(Open, Throws.Exception);
|
||||
Assert.That(Open, Throws.TypeOf<CultStoreUnreadableException>());
|
||||
}
|
||||
|
||||
[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<CultStoreUnreadableException>());
|
||||
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<CultStoreUnreadableException>());
|
||||
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<CultStoreUnreadableException>(() =>
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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<NotSupportedException>(() => Open(path).Dispose())!;
|
||||
var error = Assert.Throws<CultStoreUnreadableException>(() => 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<NotSupportedException>(() => Open(path).Dispose())!;
|
||||
var error = Assert.Throws<CultStoreUnreadableException>(() => 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<NotSupportedException>(() => Open(path).Dispose())!;
|
||||
var error = Assert.Throws<CultStoreUnreadableException>(() => Open(path).Dispose())!;
|
||||
Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("op 7"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,8 +77,16 @@ public sealed class CultMeshSingleFileReadabilityTests
|
|||
continue;
|
||||
}
|
||||
|
||||
Assert.That(() => write(path), Throws.Exception, name);
|
||||
Assert.That(() => write(path), Throws.TypeOf<CultStoreUnreadableException>(), 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NotSupportedException>(() =>
|
||||
private static CultStoreUnreadableException Refusal(string vector) =>
|
||||
Assert.Throws<CultStoreUnreadableException>(() =>
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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<30>“”ª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
|
||||
|
|
@ -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<30>“”ª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
|
||||
|
|
@ -0,0 +1 @@
|
|||
“²cultcache.store.v1
|
||||
|
|
@ -0,0 +1 @@
|
|||
“²cultcache.store.v3
|
||||
Binary file not shown.
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
‘²cultcache.store.v1
|
||||
|
|
@ -0,0 +1 @@
|
|||
‘²cultcache.store.v3
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
“²cultcache.store.v3<76>’”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta
|
||||
Binary file not shown.
Loading…
Reference in a new issue