CultMesh: a raw write refuses a file whose header it cannot read

ExistingHeader read the whole snapshot and swallowed every failure into "no header": a
truncated v3 file got a v1 raw write and an unknown v9 file was overwritten, where
TypeScript, Python and Rust refuse. CultDocumentMessagePackSerialization.ReadStoreHeader
reads the prefix header, refuses a file that is not one complete MessagePack value or whose
format this runtime cannot read, and the raw write keeps a whole marked file marked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 22:53:13 +02:00
commit aa6b11006f
3 changed files with 58 additions and 8 deletions

View file

@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Generic;
@ -240,6 +241,36 @@ 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.
/// </summary>
public static string? ReadStoreHeader(byte[] bytes)
{
if (bytes.Length == 0)
return null;
try
{
var whole = new MessagePackReader(bytes);
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);
}
var reader = new MessagePackReader(bytes);
if (reader.NextMessagePackType != MessagePackType.Array || 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 });
return header;
}
private static void WritePersistedRecord(ref MessagePackWriter writer, CultPersistedRecord record)
{
writer.WriteArrayHeader(record.Variant == null ? PersistedRecordFieldCount : VariantRecordFieldCount);

View file

@ -268,14 +268,8 @@ namespace GameCult.Mesh
private static string? ExistingHeader(string path)
{
try
{
return CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or MessagePackSerializationException or EndOfStreamException or InvalidOperationException or NotSupportedException)
{
return null;
}
// A file that is gone has no header; one that cannot be read refuses the write rather than being overwritten.
return File.Exists(path) ? CultDocumentMessagePackSerialization.ReadStoreHeader(File.ReadAllBytes(path)) : null;
}
private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path)

View file

@ -93,6 +93,31 @@ public sealed class CultMeshSingleFileElementIdTests
HeaderOf(path).Should().Be("cultcache.store.v3", "the payload's ids are not the writer's to see");
}
// A raw write does not read the store's records, but it does not overwrite a file it cannot read either: the runtimes agree.
[Test]
public void RawPayloadWriteRefusesAFileItCannotReadAndLeavesItUntouched()
{
var schema = new CultMeshSingleFileDocumentSchema("raw:schema", "RawSchema", "1");
var marked = Path_("marked.cc");
CultMesh.WriteSingleFileDocument(marked, Key, Publication(Descriptor()));
var whole = File.ReadAllBytes(marked);
var truncated = Path_("truncated.cc");
File.WriteAllBytes(truncated, whole[..^1]);
var unknown = Path_("v9.cc");
File.WriteAllBytes(unknown, MessagePackSerializer.Serialize(new object[] { "cultcache.store.v9", Array.Empty<object>(), Array.Empty<object>() }));
foreach (var path in new[] { truncated, unknown })
{
var before = File.ReadAllBytes(path);
Assert.Throws<NotSupportedException>(() => CultMesh.WriteSingleFileDocumentPayload(path, Key, schema, null, new byte[] { 0x90 }), path);
File.ReadAllBytes(path).Should().Equal(before, path + " was rewritten");
}
CultMesh.WriteSingleFileDocumentPayload(marked, Key, schema, null, new byte[] { 0x90 });
HeaderOf(marked).Should().Be("cultcache.store.v3", "a whole marked file is still written and stays marked");
}
[Test]
public void TypedWriteRefusesADuplicateIdAndAnElementObjectTwiceAndLeavesTheFileAlone()
{