diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index a9b55c8f..aaa3802f 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -301,7 +301,7 @@ public static class CultDocumentMessagePackSerialization // A store header as a refusal may show it: echoed in the shape cultcache.store.v, 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) : ""; diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index de1ac729..41ccd76c 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -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); } diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index e3ceeaef..7f3b257b 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -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`. diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index cae0943c..a9e2f3f0 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -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; diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs index 31b5f325..7e06e5b4 100644 --- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs +++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs @@ -134,6 +134,65 @@ namespace GameCult.Caching.Tests Throws.TypeOf().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path)); } + private static byte[] StoreWithHeader(string header) => + CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot + { + FormatVersion = header, + SchemaCatalog = Array.Empty(), + Records = Array.Empty() + }); + + // 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(() => + 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()); + 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()); + 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")]