C#: a dangling link is an I/O error on read; the directory refusal echoes a header only as store version digits; contract names Rust's real type

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-02 20:49:12 +02:00
commit 425531d88e
5 changed files with 70 additions and 5 deletions

View file

@ -301,7 +301,7 @@ public static class CultDocumentMessagePackSerialization
// A store header as a refusal may show it: echoed in the shape cultcache.store.v<digits>, and otherwise described
// only by its length, since the bytes are the store's.
private static string DescribeHeader(string header)
internal static string DescribeHeader(string header)
{
const string prefix = "cultcache.store.v";
var version = header.StartsWith(prefix, StringComparison.Ordinal) ? header.Substring(prefix.Length) : "";

View file

@ -495,7 +495,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
!string.Equals(snapshot.FormatVersion, IndexedFormatVersionWithIds, StringComparison.Ordinal))
{
throw new CultStoreUnreadableException(
$"Directory store {_manifestFile.FullName} is {snapshot.FormatVersion}; only {IndexedFormatVersion} and {IndexedFormatVersionWithIds} are readable.",
$"Directory store {_manifestFile.FullName} is {CultDocumentMessagePackSerialization.DescribeHeader(snapshot.FormatVersion)}; only {IndexedFormatVersion} and {IndexedFormatVersionWithIds} are readable.",
_manifestFile.FullName);
}

View file

@ -302,8 +302,9 @@ and on rewrite alike, because the rewrite asks the same full reader as the open.
file and an empty array are an empty store; a zero-byte file is not a store and is refused as unreadable. 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. 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
`CultStoreUnreadableException` (C#), `CultCacheStoreUnreadable{path, kind}` (Rust, its kind
telling an undecodable store from an unsupported format), `StoreUnreadableError` (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`.

View file

@ -4012,12 +4012,17 @@ namespace GameCult.Caching
private byte[]? ReadDisk()
{
FileInfo.Refresh();
if (!FileInfo.Exists)
// Only nothing at the path is an empty store. A link whose target is gone is a store that cannot be reached.
if (!FileInfo.Exists && FileInfo.LinkTarget == null)
return null;
try
{
return ReadAllBytesShared(FileInfo.FullName);
}
catch (FileNotFoundException ex) when (FileInfo.LinkTarget != null)
{
throw new IOException("The store path is a link whose target cannot be reached.", ex);
}
catch (FileNotFoundException)
{
return null;

View file

@ -134,6 +134,65 @@ namespace GameCult.Caching.Tests
Throws.TypeOf<CultStoreUnreadableException>().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path));
}
private static byte[] StoreWithHeader(string header) =>
CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot
{
FormatVersion = header,
SchemaCatalog = Array.Empty<CultSchemaCatalogEntry>(),
Records = Array.Empty<CultPersistedRecord>()
});
// A refusal shows a stored header only as cultcache.store.v followed by ASCII digits; any other header is described by its
// length, so what the file says never reaches a log. A single-file store and a directory manifest are refused alike.
[TestCase("cultcache.store.v9", true, false)]
[TestCase("cultcache.store.SECRETHDR", false, false)]
[TestCase("cultcache.store.v", false, false)]
[TestCase("cultcache.store.v٣", false, false)]
[TestCase("cultcache.store.v9", true, true)]
[TestCase("cultcache.store.SECRETHDR", false, true)]
[TestCase("cultcache.store.v", false, true)]
[TestCase("cultcache.store.v٣", false, true)]
public void ARefusalEchoesAHeaderOnlyAsStoreVersionDigits(string header, bool echoed, bool directoryStore)
{
var path = Path.Combine(_directory, "header.cc");
File.WriteAllBytes(path, StoreWithHeader(header));
var refusal = Assert.Throws<CultStoreUnreadableException>(() =>
CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directoryStore }))!;
Assert.That(refusal.Message.Contains(header, StringComparison.Ordinal), Is.EqualTo(echoed), refusal.Message);
if (!echoed)
Assert.That(refusal.Message, Does.Not.Contain("SECRETHDR").And.Not.Contain("٣"));
}
// Only nothing at the path is an empty store. A link whose target is gone is an I/O error on open and on commit, never an
// empty store and never an untyped missing-file failure, and the link is left where it was.
[Test]
public void ADanglingLinkIsAnIoErrorOnOpenAndOnCommitNotAnEmptyStore()
{
var path = Path.Combine(_directory, "dangling.cc");
File.CreateSymbolicLink(path, Path.Combine(_directory, "unmounted", "store.cc"));
Assert.That(() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }), Throws.TypeOf<IOException>());
Assert.That(new FileInfo(path).LinkTarget, Is.Not.Null, "the link was replaced");
Assert.That(Directory.Exists(Path.Combine(_directory, "unmounted")), Is.False);
}
[Test]
public void ACommitOntoALinkThatDanglesAfterOpenIsAnIoErrorAndLeavesTheLink()
{
var target = Path.Combine(_directory, "target.cc");
var path = Path.Combine(_directory, "link.cc");
File.WriteAllBytes(target, File.ReadAllBytes(Path.Combine(VectorRoot(), "empty-array.bin")));
File.CreateSymbolicLink(path, target);
using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry });
File.Delete(target);
Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf<IOException>());
Assert.That(new FileInfo(path).LinkTarget, Is.Not.Null, "the link was replaced");
Assert.That(File.Exists(target), Is.False, "a write went through the link");
}
// An id one entry owns and a later entry lists as compatible names the entry that owns it.
[TestCase("own-id-over-compatible-v3.bin")]
[TestCase("compatible-before-owner-v3.bin")]