Every runtime rewrites only a file that is exactly one store

A rewrite read the header of the file it replaces but not whether the file was
a store: C# ReadStoreHeader skipped one value and ignored bytes after it, and
took a non-array value or an array whose first slot is a string that is not a
store header for a headerless legacy file (so did the C# Mesh raw write, which
uses it); TypeScript's pushAll took any non-array value or non-header first slot
for v1; Rust ignored bytes after the array.

The rule, one per runtime: a file is a store on rewrite only when it is exactly
one complete MessagePack array. Bytes after it, a value that is not an array,
and a first slot that is a string but not a header this runtime reads are
refused, and the file is left untouched. An empty file and a legacy envelope
array (first slot not a string, empty included) carry no header and are still
rewritten.

- C#: ReadStoreHeader refuses non-array values, trailing bytes and a non-header
  first string, with a NotSupportedException naming which.
- TypeScript: storeHeader (store-format.ts) decides the rewrite header;
  @msgpack/msgpack's decode already refuses trailing bytes.
- Rust: store_header(bytes, whole) walks with a cursor; the rewrite path asks
  for the whole file, the read paths keep reading as before.
- Python: push_all already reads the whole file (unpackb refuses trailing
  bytes, the legacy decoder refuses anything but a list of maps); pinned by a
  test.

Tests in each runtime run the same file matrix (truncated, trailing, scalar,
map, string first; empty, empty array, legacy), plus a C# unconditional commit
over a store with trailing bytes and the C# Mesh raw write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 00:01:58 +02:00
commit ea1bd6a797
8 changed files with 211 additions and 36 deletions

View file

@ -243,31 +243,37 @@ public static class CultDocumentMessagePackSerialization
/// <summary>
/// The format header of a store file about to be replaced by a writer that does not read the store's records: null for
/// an empty file or a legacy envelope array, the header string otherwise. The header is read from the prefix, but a file
/// that is not one complete MessagePack value or whose header this runtime cannot read is refused, never taken for a
/// legacy file, so a writer cannot overwrite what it cannot see.
/// an empty file or a legacy envelope array (an array whose first slot is not a string), the header string otherwise.
/// A file is a store only when it is 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 a string but not a header this runtime reads are all refused,
/// never taken for a legacy file, so a writer cannot overwrite what it cannot see.
/// </summary>
public static string? ReadStoreHeader(byte[] bytes)
{
if (bytes.Length == 0)
return null;
var whole = new MessagePackReader(bytes);
try
{
var whole = new MessagePackReader(bytes);
if (whole.NextMessagePackType != MessagePackType.Array)
throw new NotSupportedException($"The store is a MessagePack {whole.NextMessagePackType}, not an array; it is not rewritten.");
whole.Skip();
}
catch (Exception ex) when (ex is EndOfStreamException or MessagePackSerializationException or InvalidOperationException)
{
throw new NotSupportedException("The store is not one complete MessagePack value; it is not rewritten.", ex);
throw new NotSupportedException("The store is not one complete MessagePack array; it is not rewritten.", ex);
}
if (!whole.End)
throw new NotSupportedException("The store has bytes after its MessagePack array; it is not rewritten.");
var reader = new MessagePackReader(bytes);
if (reader.NextMessagePackType != MessagePackType.Array || reader.ReadArrayHeader() == 0 ||
reader.NextMessagePackType != MessagePackType.String)
if (reader.ReadArrayHeader() == 0 || reader.NextMessagePackType != MessagePackType.String)
return null;
var header = reader.ReadString();
if (header != null && header.StartsWith("cultcache.store.", StringComparison.Ordinal))
RequireSingleFileFormat(new CultPersistedStoreSnapshot { FormatVersion = header });
var header = reader.ReadString() ?? string.Empty;
if (!header.StartsWith("cultcache.store.", StringComparison.Ordinal))
throw new NotSupportedException($"The store's first slot is the string '{header}', not a store header; it is not rewritten.");
RequireSingleFileFormat(new CultPersistedStoreSnapshot { FormatVersion = header });
return header;
}