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:
parent
75ba6c0f5c
commit
425531d88e
5 changed files with 70 additions and 5 deletions
|
|
@ -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) : "";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
Loading…
Reference in a new issue