CultLib/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
Meta Vens 98ed042fcc C#: refuse unknown store headers and extra record slots by name
Delete the trailing Skip loop in the record reader, the ?? v1 header default,
and the mesh reader's copy of the record decoder. The single-file store and
CultMesh single-file reads accept cultcache.store.v1 only. Persistence-format
and parity docs state the rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 02:04:33 +02:00

187 lines
8.3 KiB
C#

#nullable enable
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
public class PreCut2StoreFormatTests
{
private static string FixtureRoot =>
Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "pre-cut2");
private static string CopyFixture(string name)
{
var target = Path.Combine(Path.GetTempPath(), $"cultlib-precut2-{Guid.NewGuid():N}");
var source = Path.Combine(FixtureRoot, name);
foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
{
var destination = Path.Combine(target, Path.GetRelativePath(source, file));
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
File.Copy(file, destination);
}
return target;
}
private static void AssertFixtureRecords(CultCache cache)
{
Assert.That(cache.Get<PreCut2FixtureItem>(new CultRecordKey("item:anvil")), Is.Not.Null);
Assert.That(cache.Get<PreCut2FixtureItem>(new CultRecordKey("item:anvil"))!.Name, Is.EqualTo("anvil"));
Assert.That(cache.Get<PreCut2FixtureItem>(new CultRecordKey("item:anvil"))!.Count, Is.EqualTo(3));
Assert.That(cache.Get<PreCut2FixtureItem>(new CultRecordKey("item:bellows"))?.Name, Is.EqualTo("bellows"));
Assert.That(cache.Get<PreCut2FixtureItem>(new CultRecordKey("item:bellows"))?.Count, Is.EqualTo(42));
Assert.That(cache.Get<PreCut2FixtureNote>(new CultRecordKey("note:origin"))?.Text,
Is.EqualTo("written before cut 2"));
Assert.That(cache.GetAll<PreCut2FixtureItem>(), Has.Length.EqualTo(2));
Assert.That(cache.GetAll<PreCut2FixtureNote>(), Has.Length.EqualTo(1));
}
[Test]
public async Task DirectoryStoreOpensV4ManifestWrittenBeforeCut2()
{
var root = CopyFixture("directory-v4");
try
{
var manifest = Path.Combine(root, "store.cc");
Assert.That(CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(manifest)).FormatVersion,
Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages"));
using var cache = new CultCache();
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await cache.PullAllBackingStoresAsync();
AssertFixtureRecords(cache);
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Test]
public async Task SingleFileOpensV1SnapshotWrittenBeforeCut2()
{
var root = CopyFixture("single-file-v1");
try
{
var file = Path.Combine(root, "store.msgpack");
Assert.That(CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file)).FormatVersion,
Is.EqualTo("cultcache.store.v1"));
using var cache = new CultCache();
cache.AddBackingStore(new SingleFileMessagePackBackingStore(file));
await cache.PullAllBackingStoresAsync();
AssertFixtureRecords(cache);
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
private const string ItemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788";
private static string VectorPath(string name)
{
for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c0", name);
if (File.Exists(candidate)) return candidate;
}
throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}.");
}
private static async Task<string> PullRefusalAsync(string vector)
{
var root = Path.Combine(Path.GetTempPath(), $"cultlib-vector-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
try
{
var file = Path.Combine(root, "store.msgpack");
File.Copy(VectorPath(vector), file);
using var cache = new CultCache();
cache.AddBackingStore(new SingleFileMessagePackBackingStore(file));
var error = Assert.CatchAsync(async () => await cache.PullAllBackingStoresAsync());
Assert.That(error, Is.Not.Null);
Assert.That(error!.ToString(), Does.Contain(nameof(NotSupportedException)));
return error.ToString();
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Test]
public async Task SingleFileRefusesUnknownHeaderByName()
{
Assert.That(await PullRefusalAsync("unknown-header.msgpack"), Does.Contain("cultcache.store.v9"));
}
[Test]
public async Task SingleFileRefusesExtraRecordSlotNamingTheRecord()
{
var message = await PullRefusalAsync("extra-slot-full-payload.msgpack");
Assert.That(message, Does.Contain("item:anvil"));
Assert.That(message, Does.Contain(ItemSchemaId));
}
[Test]
public async Task SingleFileRefusesVariantStoreByVersionOrRecord()
{
Assert.That(await PullRefusalAsync("variant-v2.msgpack"),
Does.Contain("cultcache.store.v2").Or.Contain("item:anvil-big"));
}
[Test]
public void V1StoreWrittenAtTheBaseCommitStillDecodesByteForByte()
{
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(VectorPath("v1-base.msgpack")));
CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
Assert.That(snapshot.FormatVersion, Is.EqualTo("cultcache.store.v1"));
Assert.That(snapshot.Records.Select(record => record.Key), Is.EqualTo(new[] { "alpha", "beta" }));
Assert.That(snapshot.Records[0].Payload, Is.EqualTo(new byte[] { 0x92, 0xa5, (byte)'a', (byte)'l', (byte)'p', (byte)'h', (byte)'a', 0x01 }));
Assert.That(snapshot.Records[1].Payload, Is.EqualTo(new byte[] { 0x92, 0xa4, (byte)'b', (byte)'e', (byte)'t', (byte)'a', 0x02 }));
}
// Format strings taken from DirectoryMessagePackBackingStore.cs at 0db1fe5.
[TestCase("cultcache.store.v1")]
[TestCase("cultcache.store.v1.directory")]
[TestCase("cultcache.store.v2.directory-indexed")]
[TestCase("cultcache.store.v3.directory-immutable-pages")]
public void DirectoryStoreRefusesNonV4Manifest(string formatVersion)
{
var root = Path.Combine(Path.GetTempPath(), $"cultlib-refuse-{Guid.NewGuid():N}");
var manifest = Path.Combine(root, "store.cc");
Directory.CreateDirectory(root);
try
{
var bytes = CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot
{
FormatVersion = formatVersion,
SchemaCatalog = Array.Empty<CultSchemaCatalogEntry>(),
Records = Array.Empty<CultPersistedRecord>()
});
File.WriteAllBytes(manifest, bytes);
using var cache = new CultCache();
Assert.That(() => cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains(formatVersion));
Assert.That(cache.BackingStores, Is.Empty);
Assert.That(File.ReadAllBytes(manifest), Is.EqualTo(bytes));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
}
}