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:
Meta Vens 2026-09-30 04:06:44 +02:00
commit 436eed73e4
23 changed files with 186 additions and 84 deletions

View file

@ -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}.");
}
}