2026-09-13 17:17:14 +02:00
#nullable enable
using System ;
using System.IO ;
2026-09-29 02:04:33 +02:00
using System.Linq ;
2026-09-13 17:17:14 +02:00
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 ) ;
}
}
2026-09-29 02:04:33 +02:00
// 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}." ) ;
}
2026-09-29 02:35:57 +02:00
private static string Refusal ( string vector )
2026-09-29 02:04:33 +02:00
{
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 ( ) ;
2026-09-29 02:35:57 +02:00
var error = Assert . Catch ( ( ) = > cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ) ;
2026-09-29 02:04:33 +02:00
Assert . That ( error , Is . Not . Null ) ;
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
Assert . That ( error , Is . TypeOf < CultStoreUnreadableException > ( ) ) ;
2026-09-29 02:04:33 +02:00
return error . ToString ( ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
[Test]
2026-09-29 02:35:57 +02:00
public void SingleFileRefusesUnknownHeaderByName ( )
2026-09-29 02:04:33 +02:00
{
2026-09-29 02:35:57 +02:00
Assert . That ( Refusal ( "unknown-header.msgpack" ) , Does . Contain ( "cultcache.store.v9" ) ) ;
2026-09-29 02:04:33 +02:00
}
2026-09-30 06:54:17 +02:00
// Slots of the current catalog layout that do not decode are refused by name, as a malformed store is.
[TestCase("current-catalog-bad-canonical.msgpack")]
[TestCase("current-catalog-bad-members.msgpack")]
[TestCase("current-catalog-bad-compat.msgpack")]
2026-09-30 08:17:59 +02:00
[TestCase("current-catalog-bad-slot5-nil.msgpack")]
[TestCase("current-catalog-bad-slot5-int.msgpack")]
2026-09-30 06:54:17 +02:00
public void SingleFileRefusesAMalformedCurrentCatalogEntry ( string vector )
{
Assert . That ( Refusal ( vector ) , Is . Not . Empty ) ;
}
2026-10-22 15:39:40 +02:00
// The release-now-c2a-bumps-format ruling rests on this: a store stamped with a newer format version is refused at
// open, so this runtime cannot rewrite it. The file stays byte-identical through the refusal and through later
// writes to the cache, whose refused store was never attached.
2026-10-22 20:21:31 +02:00
[TestCase("cultcache.store.v4")]
2026-10-22 15:39:40 +02:00
[TestCase("cultcache.store.v9")]
public async Task SingleFileStampedWithANewerFormatIsRefusedAndNeverRewritten ( string newerFormat )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
var snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( file ) ) ;
snapshot . FormatVersion = newerFormat ;
File . WriteAllBytes ( file , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
var before = File . ReadAllBytes ( file ) ;
using var cache = new CultCache ( ) ;
2026-10-22 20:49:43 +02:00
var error = Assert . Throws < CultStoreUnreadableException > ( ( ) = >
2026-10-22 15:39:40 +02:00
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ) ! ;
Assert . That ( error . Message , Does . Contain ( newerFormat ) ) ;
Assert . That ( cache . BackingStores , Is . Empty ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "written after the refusal" } ) ;
await cache . FlushAsync ( ) ;
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( before ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 20:21:31 +02:00
// Two processes: this one attached a v1 store, then a later build rewrote it as v4. A plain flush, a staged write
2026-10-22 16:43:11 +02:00
// and a staged delete must each refuse to downgrade it, leaving the file as the other process wrote it.
[Test]
public async Task SingleFileFlushRefusesAStoreAnotherProcessStampedNewerSinceAttach ( )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
var snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( file ) ) ;
2026-10-22 20:21:31 +02:00
snapshot . FormatVersion = "cultcache.store.v4" ;
2026-10-22 16:43:11 +02:00
File . WriteAllBytes ( file , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
var before = File . ReadAllBytes ( file ) ;
2026-10-22 20:21:31 +02:00
await cache . AddAsync ( new PreCut2FixtureNote { Text = "staged after the other process wrote v4" } ) ;
2026-10-22 20:49:43 +02:00
Assert . Throws < CultStoreUnreadableException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
2026-10-22 16:43:11 +02:00
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( before ) ) ;
cache . Remove ( new CultRecordKey ( "item:anvil" ) ) ;
2026-10-22 20:49:43 +02:00
Assert . Throws < CultStoreUnreadableException > ( ( ) = > cache . BackingStores . Single ( ) . PushAll ( ) ) ;
2026-10-22 16:43:11 +02:00
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( before ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 20:21:31 +02:00
// A write set cannot merge with a disk it cannot read: a flush refuses a store whose stamp is unreadable and leaves it
// as it is.
2026-10-22 16:43:11 +02:00
[Test]
2026-10-22 20:21:31 +02:00
public async Task SingleFileFlushRefusesAStoreWhoseStampIsUnreadable ( )
2026-10-22 16:43:11 +02:00
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
2026-10-22 20:21:31 +02:00
var damaged = new byte [ ] { 0xc1 , 0xc1 } ;
File . WriteAllBytes ( file , damaged ) ;
2026-10-22 16:43:11 +02:00
2026-10-22 20:21:31 +02:00
await cache . AddAsync ( new PreCut2FixtureNote { Text = "refused" } ) ;
Assert . Throws < CultStoreUnreadableException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
2026-10-22 16:43:11 +02:00
2026-10-22 20:21:31 +02:00
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( damaged ) ) ;
2026-10-22 16:43:11 +02:00
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 17:30:57 +02:00
private static byte [ ] Raw ( params object [ ] parts )
{
var bytes = new System . Collections . Generic . List < byte > ( ) ;
foreach ( var part in parts )
{
switch ( part )
{
case byte single : bytes . Add ( single ) ; break ;
case string text :
var encoded = System . Text . Encoding . UTF8 . GetBytes ( text ) ;
bytes . Add ( 0xd9 ) ; bytes . Add ( ( byte ) encoded . Length ) ; bytes . AddRange ( encoded ) ; break ;
case byte [ ] binary :
bytes . Add ( 0xc4 ) ; bytes . Add ( ( byte ) binary . Length ) ; bytes . AddRange ( binary ) ; break ;
default : throw new ArgumentException ( part . GetType ( ) . Name ) ;
}
}
return bytes . ToArray ( ) ;
}
// The format decision comes from the header alone. A stamp this runtime does not read refuses whatever follows it,
// so a body that will not parse can never make a newer store look like damage a flush may heal.
private static System . Collections . Generic . IEnumerable < TestCaseData > NewerStampOverUnreadableBody ( )
{
byte garbage = 0xc1 ;
2026-10-22 23:53:10 +02:00
yield return new TestCaseData ( Raw ( ( byte ) 0x93 , "cultcache.store.v4" , garbage , garbage ) , "cultcache.store.v4" ) . SetName ( "v4 header over a bad body" ) ;
yield return new TestCaseData ( Raw ( ( byte ) 0x93 , System . Text . Encoding . UTF8 . GetBytes ( "cultcache.store.v3" ) , garbage , garbage ) , "binary" ) . SetName ( "bin header v3 over a bad body" ) ;
yield return new TestCaseData ( Raw ( ( byte ) 0x93 , System . Text . Encoding . UTF8 . GetBytes ( "cultcache.store.v9" ) , garbage , garbage ) , "binary" ) . SetName ( "bin header v9 over a bad body" ) ;
yield return new TestCaseData ( Raw ( ( byte ) 0x93 , "cultcache.store.v4" , ( byte ) 0x91 , garbage , ( byte ) 0x90 ) , "cultcache.store.v4" ) . SetName ( "v4 header over a bad catalog" ) ;
yield return new TestCaseData ( Raw ( ( byte ) 0x92 , "cultcache.store.v4" , garbage ) , "cultcache.store.v4" ) . SetName ( "v4 header over garbage" ) ;
yield return new TestCaseData ( Raw ( ( byte ) 0x93 , "cultcache.store.v5" , garbage , garbage ) , "cultcache.store.v5" ) . SetName ( "v5 header over a bad body" ) ;
yield return new TestCaseData ( BinaryStampOverAValidBody ( ) , "binary" ) . SetName ( "bin header v1 over a valid body" ) ;
}
// The single-file-v1 fixture's own bytes with the stamp re-encoded from a string to bin8: nothing else differs, so only the
// stamp's encoding can refuse it.
private static byte [ ] BinaryStampOverAValidBody ( )
{
var bytes = File . ReadAllBytes ( Path . Combine ( AppContext . BaseDirectory , "Fixtures" , "pre-cut2" , "single-file-v1" , "store.msgpack" ) ) ;
var stamp = System . Text . Encoding . UTF8 . GetBytes ( "cultcache.store.v1" ) ;
var at = 1 ;
Assert . That ( bytes [ 0 ] , Is . EqualTo ( 0x93 ) , "the fixture opens with a three-slot array" ) ;
Assert . That ( bytes [ at ] , Is . EqualTo ( ( byte ) ( 0xa0 | stamp . Length ) ) , "the stamp is a fixstr" ) ;
var rebuilt = new System . Collections . Generic . List < byte > { bytes [ 0 ] , 0xc4 , ( byte ) stamp . Length } ;
rebuilt . AddRange ( stamp ) ;
rebuilt . AddRange ( bytes . Skip ( at + 1 + stamp . Length ) ) ;
return rebuilt . ToArray ( ) ;
2026-10-22 17:30:57 +02:00
}
[TestCaseSource(nameof(NewerStampOverUnreadableBody))]
2026-10-22 23:53:10 +02:00
public async Task SingleFileFlushRefusesANewerStampWhateverItsBodyHolds ( byte [ ] swapped , string named )
2026-10-22 17:30:57 +02:00
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
File . WriteAllBytes ( file , swapped ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "must not overwrite" } ) ;
2026-10-22 23:53:10 +02:00
var flush = Assert . Throws < CultStoreUnreadableException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ! ;
Assert . That ( flush . Message , Does . Contain ( named ) ) ;
2026-10-22 17:30:57 +02:00
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( swapped ) ) ;
2026-10-22 23:53:10 +02:00
var pull = Assert . Throws < CultStoreUnreadableException > ( ( ) = > new SingleFileMessagePackBackingStore ( file ) . PullAll ( ) ) ! ;
Assert . That ( pull . Message , Does . Contain ( named ) ) ;
2026-10-22 17:30:57 +02:00
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
[Test]
public async Task DirectoryWritesRefuseANewerStampWhateverTheManifestBodyHolds ( )
{
var root = CopyFixture ( "directory-v4" ) ;
try
{
var manifest = Path . Combine ( root , "store.cc" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new DirectoryMessagePackBackingStore ( manifest ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
2026-10-22 20:21:31 +02:00
var swapped = Raw ( ( byte ) 0x93 , "cultcache.store.v6.directory-content-addressed-pages" , ( byte ) 0xc1 , ( byte ) 0xc1 ) ;
2026-10-22 17:30:57 +02:00
File . WriteAllBytes ( manifest , swapped ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "must not overwrite" } ) ;
2026-10-22 20:49:43 +02:00
Assert . Throws < CultStoreUnreadableException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
2026-10-22 17:30:57 +02:00
Assert . That ( File . ReadAllBytes ( manifest ) , Is . EqualTo ( swapped ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 20:21:31 +02:00
// A file that is read but carries no readable stamp (empty, or cut off) is not a store: a flush refuses it, whatever its
// length, and leaves it as it is.
2026-10-22 17:30:57 +02:00
[TestCase(0)]
[TestCase(1)]
[TestCase(10)]
[TestCase(-1)]
2026-10-22 20:21:31 +02:00
public async Task SingleFileFlushRefusesAnEmptyOrTruncatedFile ( int keep )
2026-10-22 17:30:57 +02:00
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
var whole = File . ReadAllBytes ( file ) ;
2026-10-22 20:21:31 +02:00
var damaged = whole . Take ( keep < 0 ? whole . Length / 2 : keep ) . ToArray ( ) ;
File . WriteAllBytes ( file , damaged ) ;
2026-10-22 17:30:57 +02:00
2026-10-22 20:21:31 +02:00
await cache . AddAsync ( new PreCut2FixtureNote { Text = "refused" } ) ;
Assert . Throws < CultStoreUnreadableException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
2026-10-22 17:30:57 +02:00
2026-10-22 20:21:31 +02:00
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( damaged ) ) ;
2026-10-22 17:30:57 +02:00
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 20:21:31 +02:00
// A store subclass that overrides nothing beyond its abstract members is guarded by the base, not by an opt-in.
2026-10-22 17:00:10 +02:00
private sealed class PlainCustomStore : SingleFileBackingStore
{
public PlainCustomStore ( string path ) : base ( path ) { }
2026-10-22 20:21:31 +02:00
protected override byte [ ] SerializeStore ( string formatVersion , System . Collections . Generic . IReadOnlyList < byte [ ] > catalogEntries , System . Collections . Generic . IReadOnlyList < byte [ ] > records ) = >
CultDocumentMessagePackSerialization . SerializeStore ( formatVersion , catalogEntries , records ) ;
protected override byte [ ] SerializeRecord ( CultPersistedRecord record ) = >
CultDocumentMessagePackSerialization . SerializePersistedRecord ( record ) ;
protected override byte [ ] SerializeCatalogEntry ( CultSchemaCatalogEntry entry ) = >
CultDocumentMessagePackSerialization . SerializeCatalogEntry ( entry ) ;
2026-10-22 17:00:10 +02:00
2026-10-22 20:21:31 +02:00
protected override ( CultPersistedStoreSnapshot Snapshot , byte [ ] [ ] CatalogBytes , byte [ ] [ ] RecordBytes ) ReadStore ( byte [ ] data )
2026-10-22 17:00:10 +02:00
{
2026-10-22 20:21:31 +02:00
var store = CultDocumentMessagePackSerialization . ReadStore ( data ) ;
CultDocumentMessagePackSerialization . RequireSingleFileFormat ( store . Snapshot ) ;
return store ;
2026-10-22 17:00:10 +02:00
}
2026-10-23 01:38:16 +02:00
protected override byte [ ] SerializePayload ( object document , Type documentType ) = >
CultDocumentMessagePackSerialization . SerializeUntyped ( document , documentType , Registry ) ;
2026-10-22 17:00:10 +02:00
protected override object DeserializePayload ( Type documentType , byte [ ] payload ) = >
CultDocumentMessagePackSerialization . DeserializeUntyped ( documentType , payload , Registry ) ;
}
private static void StampHeader ( string file , string header )
{
var snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( file ) ) ;
snapshot . FormatVersion = header ;
File . WriteAllBytes ( file , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
}
[Test]
public async Task CustomSingleFileStoreThatOverridesNothingIsRefusedAtFlushOfANewerStore ( )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new PlainCustomStore ( file ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
2026-10-22 20:21:31 +02:00
StampHeader ( file , "cultcache.store.v4" ) ;
2026-10-22 17:00:10 +02:00
var before = File . ReadAllBytes ( file ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "staged" } ) ;
2026-10-22 20:49:43 +02:00
Assert . Throws < CultStoreUnreadableException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
2026-10-22 17:00:10 +02:00
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( before ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
// Attach, commit and flush share one rule for which formats are readable: a header the reader refuses at attach is
// refused at flush, whatever its spelling.
[TestCase("cultcache.store.v3-ids")]
[TestCase("cultcache.store.v1000000")]
[TestCase("cultcache.v3")]
[TestCase("cultcache.store.v3")]
[TestCase("cultcache.store.v2")]
[TestCase("cultcache.store.v1")]
public async Task FlushRefusesExactlyTheHeadersAttachRefuses ( string header )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
StampHeader ( file , header ) ;
var before = File . ReadAllBytes ( file ) ;
bool attachRefuses ;
using ( var fresh = new CultCache ( ) )
{
try { fresh . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ; attachRefuses = false ; }
catch ( NotSupportedException ) { attachRefuses = true ; }
}
await cache . AddAsync ( new PreCut2FixtureNote { Text = "staged" } ) ;
var flushRefuses = false ;
try { cache . FlushAllBackingStores ( ) ; }
catch ( NotSupportedException ) { flushRefuses = true ; }
Assert . That ( flushRefuses , Is . EqualTo ( attachRefuses ) , header ) ;
if ( attachRefuses ) Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( before ) , header ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 18:32:34 +02:00
// The refusal comes from the read, not from a failed write onto the held file: the store never reaches serialising.
private sealed class WriteCountingStore : SingleFileMessagePackBackingStore
{
public int Serialisations ;
public WriteCountingStore ( string path ) : base ( path ) { }
2026-10-22 20:21:31 +02:00
protected override byte [ ] SerializeStore ( string formatVersion , System . Collections . Generic . IReadOnlyList < byte [ ] > catalogEntries , System . Collections . Generic . IReadOnlyList < byte [ ] > records )
2026-10-22 18:32:34 +02:00
{
Serialisations + + ;
2026-10-22 20:21:31 +02:00
return base . SerializeStore ( formatVersion , catalogEntries , records ) ;
2026-10-22 18:32:34 +02:00
}
}
// The public parser judges no stamp, as in 1.0.61: a v4 directory manifest and a v3 header both parse.
[Test]
public void PublicParserReadsAnyStampAsEveryReleaseHas ( )
{
var root = CopyFixture ( "directory-v4" ) ;
try
{
var manifest = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( Path . Combine ( root , "store.cc" ) ) ) ;
Assert . That ( manifest . FormatVersion , Is . EqualTo ( "cultcache.store.v4.directory-content-addressed-pages" ) ) ;
var v3 = new CultPersistedStoreSnapshot { FormatVersion = "cultcache.store.v3" , SchemaCatalog = Array . Empty < CultSchemaCatalogEntry > ( ) , Records = Array . Empty < CultPersistedRecord > ( ) } ;
var parsed = CultDocumentMessagePackSerialization . DeserializeSnapshot ( CultDocumentMessagePackSerialization . SerializeSnapshot ( v3 ) ) ;
Assert . That ( parsed . FormatVersion , Is . EqualTo ( "cultcache.store.v3" ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 17:00:10 +02:00
// A read that fails on I/O is not an unreadable stamp to heal over: the flush refuses and the file is untouched.
[Test]
public async Task SingleFileFlushRefusesWhenReadingTheDiskFailsOnIO ( )
{
var root = CopyFixture ( "single-file-v1" ) ;
try
{
var file = Path . Combine ( root , "store.msgpack" ) ;
using var cache = new CultCache ( ) ;
2026-10-22 18:32:34 +02:00
var store = new WriteCountingStore ( file ) ;
cache . AddBackingStore ( store ) ;
2026-10-22 17:00:10 +02:00
await cache . PullAllBackingStoresAsync ( ) ;
var before = File . ReadAllBytes ( file ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "staged" } ) ;
2026-10-22 18:32:34 +02:00
store . Serialisations = 0 ;
2026-10-22 17:33:24 +02:00
using ( new FileStream ( file , FileMode . Open , FileAccess . ReadWrite , FileShare . None ) )
{
Assert . Throws < IOException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
}
2026-10-22 18:32:34 +02:00
Assert . That ( store . Serialisations , Is . Zero , "the refusal is the read failing, not the write onto the held file failing" ) ;
2026-10-22 17:00:10 +02:00
Assert . That ( File . ReadAllBytes ( file ) , Is . EqualTo ( before ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 20:21:31 +02:00
[TestCase("cultcache.store.v6.directory-content-addressed-pages")]
2026-10-22 16:43:11 +02:00
[TestCase("cultcache.store.v9")]
public async Task DirectoryWritesRefuseAStoreAnotherProcessStampedNewerSinceAttach ( string newer )
{
var root = CopyFixture ( "directory-v4" ) ;
try
{
var manifest = Path . Combine ( root , "store.cc" ) ;
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new DirectoryMessagePackBackingStore ( manifest ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
2026-10-22 18:32:34 +02:00
var snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( manifest ) ) ;
2026-10-22 16:43:11 +02:00
snapshot . FormatVersion = newer ;
File . WriteAllBytes ( manifest , CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
Func < System . Collections . Generic . Dictionary < string , byte [ ] > > files = ( ) = > Directory
. GetFiles ( root , "*" , SearchOption . AllDirectories )
. Where ( path = > ! path . EndsWith ( ".lock" , StringComparison . Ordinal ) )
. ToDictionary ( path = > path , File . ReadAllBytes ) ;
var before = files ( ) ;
await cache . AddAsync ( new PreCut2FixtureNote { Text = "staged after the other process wrote the newer format" } ) ;
2026-10-22 20:49:43 +02:00
Assert . Throws < CultStoreUnreadableException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
2026-10-22 16:43:11 +02:00
Assert . That ( files ( ) . Keys , Is . EquivalentTo ( before . Keys ) ) ;
foreach ( var pair in before ) Assert . That ( File . ReadAllBytes ( pair . Key ) , Is . EqualTo ( pair . Value ) , pair . Key ) ;
cache . Remove ( new CultRecordKey ( "item:anvil" ) ) ;
2026-10-22 20:49:43 +02:00
Assert . Throws < CultStoreUnreadableException > ( ( ) = > cache . FlushAllBackingStores ( ) ) ;
2026-10-22 16:43:11 +02:00
Assert . That ( files ( ) . Keys , Is . EquivalentTo ( before . Keys ) ) ;
foreach ( var pair in before ) Assert . That ( File . ReadAllBytes ( pair . Key ) , Is . EqualTo ( pair . Value ) , pair . Key ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-09-29 02:04:33 +02:00
[Test]
2026-09-29 02:35:57 +02:00
public void SingleFileRefusesExtraRecordSlotNamingTheRecord ( )
2026-09-29 02:04:33 +02:00
{
2026-09-29 02:35:57 +02:00
var message = Refusal ( "extra-slot-full-payload.msgpack" ) ;
2026-09-29 02:04:33 +02:00
Assert . That ( message , Does . Contain ( "item:anvil" ) ) ;
Assert . That ( message , Does . Contain ( ItemSchemaId ) ) ;
}
2026-09-29 03:52:35 +02:00
// C1 reads v2. This vector was written by hand before C1 existed and its variant keeps its base's name, so a
// cache with a codec refuses it under R6, naming both records; a cache without one cannot resolve it at all.
2026-09-29 02:04:33 +02:00
[Test]
2026-09-29 03:52:35 +02:00
public void SingleFileRefusesTheHandWrittenVariantVectorNamingTheRecords ( )
2026-09-29 02:04:33 +02:00
{
2026-09-29 03:52:35 +02:00
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 ( "variant-v2.msgpack" ) , file ) ;
using var withCodec = new CultCache ( CultDocumentRegistry . Shared , CultCacheMessagePack . CreateCodec ( CultDocumentRegistry . Shared ) ) ;
var named = Assert . Throws < InvalidOperationException > ( ( ) = > withCodec . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ) ! ;
Assert . That ( named . Message , Does . Contain ( "item:anvil-big" ) . And . Contain ( "item:anvil" ) ) ;
using var without = new CultCache ( ) ;
var codecless = Assert . Throws < InvalidOperationException > ( ( ) = > without . AddBackingStore ( new SingleFileMessagePackBackingStore ( file ) ) ) ! ;
Assert . That ( codecless . Message , Does . Contain ( "item:anvil-big" ) . And . Contain ( "codec" ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
2026-09-29 02:04:33 +02:00
}
2026-09-29 02:35:57 +02:00
[Test]
public void SnapshotWithoutAFormatVersionIsRefusedNotDefaultedToV1 ( )
{
// [nil, [], []]
var bytes = new byte [ ] { 0x93 , 0xc0 , 0x90 , 0x90 } ;
Assert . That ( ( ) = > CultDocumentMessagePackSerialization . DeserializeSnapshot ( bytes ) ,
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
Throws . TypeOf < CultStoreUnreadableException > ( ) . With . Message . Contains ( "format version" ) ) ;
2026-09-29 02:35:57 +02:00
}
2026-09-30 17:05:15 +02:00
// Every runtime reads a store file the same way: a missing file is an empty store, an existing empty file is not
// a store (no CultCache writer leaves one), and an empty array carries no header, so it claims no format and is an
// empty store too.
[Test]
public async Task SingleFileRefusesAnEmptyFileAndOpensAMissingFileOrAnEmptyArrayAsEmpty ( )
{
var root = Path . Combine ( Path . GetTempPath ( ) , $"cultlib-empty-{Guid.NewGuid():N}" ) ;
Directory . CreateDirectory ( root ) ;
try
{
var empty = Path . Combine ( root , "empty.msgpack" ) ;
File . WriteAllBytes ( empty , Array . Empty < byte > ( ) ) ;
using ( var cache = new CultCache ( ) )
{
Assert . Catch ( ( ) = > cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( empty ) ) ) ;
Assert . That ( cache . BackingStores , Is . Empty ) ;
}
Assert . That ( File . ReadAllBytes ( empty ) , Is . Empty ) ;
var emptyArray = Path . Combine ( root , "empty-array.msgpack" ) ;
File . WriteAllBytes ( emptyArray , new byte [ ] { 0x90 } ) ;
foreach ( var path in new [ ] { Path . Combine ( root , "missing.msgpack" ) , emptyArray } )
{
using var cache = new CultCache ( ) ;
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( path ) ) ;
await cache . PullAllBackingStoresAsync ( ) ;
Assert . That ( cache . GetAll < PreCut2FixtureItem > ( ) , Is . Empty , path ) ;
}
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-09-30 18:35:20 +02:00
// A refusal echoes a header only in the shape cultcache.store.v<digits>; any other is described by its length.
[TestCase("cultcache.store.v12", true)]
[TestCase("cultcache.store.SECRET-HEADER", false)]
[TestCase("cultcache.store.v12SECRET", false)]
[TestCase("cultcache.store.v", false)]
public void SingleFileRefusalEchoesAHeaderOnlyInTheKnownShape ( string header , bool echoed )
{
var snapshot = new CultPersistedStoreSnapshot { FormatVersion = header } ;
Merge CultCache R1 (f789c8d1) into C2a: one reader per runtime, R1's failure taxonomy
R1's rules win where both sides decided the same question: a zero-byte file is
not a store (refused as unreadable in every runtime), only nothing at the path is
empty, a dangling link is an I/O error, refusals never echo stored values and echo
a header only as cultcache.store.v<digits>, writes report Rejected, NotReplaced
and ReplacedNotDurable. C2a's rules win on what it added: the v3 element-id
header, variants, whole-file proof, typed unreadable errors in C#, TypeScript and
Python. Rust keeps R1's CultCacheStoreUnreadable{path, kind}; C2a's
StoreUnreadableError is deleted. The readability manifest's zero-byte row is
refuses x4.
Not yet built or tested: verification follows in the next commits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:41:35 +02:00
var error = Assert . Throws < CultStoreUnreadableException > ( ( ) = > CultDocumentMessagePackSerialization . RequireSingleFileFormat ( snapshot ) ) ! ;
2026-10-02 20:09:17 +02:00
Assert . That ( error . Message , Does . Contain ( $"this runtime reads {CultPersistedStoreSnapshot.FormatV1}, {CultPersistedStoreSnapshot.FormatV2} and {CultPersistedStoreSnapshot.FormatV3}." ) ) ;
2026-09-30 18:35:20 +02:00
if ( echoed )
{
Assert . That ( error . Message , Does . Contain ( "format " + header + " is not" ) ) ;
}
else
{
Assert . That ( error . Message , Does . Not . Contain ( "SECRET" ) . And . Not . Contain ( "format " + header + " is not" ) ) ;
Assert . That ( error . Message , Does . Contain ( $"of {header.Length} bytes" ) ) ;
}
}
2026-10-22 20:21:31 +02:00
// The stamp decides readability and the body never does: a newer stamp is named whatever follows it, without the body parsed.
[Test]
public void ReadStoreNamesANewerSingleFileStampWithoutParsingTheBody ( )
{
var garbage = ( byte ) 0xc1 ;
var bytes = Raw ( ( byte ) 0x93 , "cultcache.store.v4" , garbage , garbage ) ;
var error = Assert . Throws < CultStoreUnreadableException > ( ( ) = >
CultDocumentMessagePackSerialization . ReadStore ( bytes , CultDocumentMessagePackSerialization . RequireReadableSingleFileStamp ) ) ! ;
Assert . That ( error . Message , Does . Contain ( "format cultcache.store.v4 is not readable" ) ) ;
}
// A snapshot with no format version has no stamp to write, so serialising it is refused rather than writing a nil slot 0.
[Test]
public void SerializeSnapshotRefusesASnapshotWithNoFormatVersion ( )
{
var snapshot = new CultPersistedStoreSnapshot { FormatVersion = null ! } ;
Assert . Throws < ArgumentException > ( ( ) = > CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ) ;
}
2026-09-29 02:04:33 +02:00
[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 } ) ) ;
}
2026-09-13 17:17:14 +02:00
// 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 ( ) ;
2026-10-02 21:00:51 +02:00
// The refusal names the manifest, and shows the header only when it is cultcache.store.v<digits>: the suffixed
// names a directory store once wrote are stored text like any other.
var shown = System . Text . RegularExpressions . Regex . IsMatch ( formatVersion , @"^cultcache\.store\.v[0-9]+$" ) ;
var refusal = Assert . Throws < CultStoreUnreadableException > ( ( ) = > cache . AddBackingStore ( new DirectoryMessagePackBackingStore ( manifest ) ) ) ! ;
Assert . That ( refusal . Path , Is . EqualTo ( manifest ) ) ;
Assert . That ( refusal . Message . Contains ( " is " + formatVersion + ";" , StringComparison . Ordinal ) , Is . EqualTo ( shown ) , refusal . Message ) ;
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
Assert . That ( cache . BackingStores , Is . Empty ) ;
2026-09-13 17:17:14 +02:00
Assert . That ( File . ReadAllBytes ( manifest ) , Is . EqualTo ( bytes ) ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-10-22 23:53:10 +02:00
// A header is named whole only in the shape cultcache.store.v<1-6 digits>, with a suffix counted and never echoed, and
// described by its length otherwise.
[TestCase("cultcache.store.v3", "cultcache.store.v3")]
[TestCase("cultcache.store.v10", "cultcache.store.v10")]
[TestCase("cultcache.store.v40", "cultcache.store.v40")]
[TestCase("cultcache.store.v123456", "cultcache.store.v123456")]
[TestCase("cultcache.store.v1234567", "an unrecognised cultcache.store.* header of 24 bytes")]
[TestCase("cultcache.store.v", "an unrecognised cultcache.store.* header of 17 bytes")]
[TestCase("cultcache.store.vx", "an unrecognised cultcache.store.* header of 18 bytes")]
[TestCase("cultcache.store.v3.extra", "cultcache.store.v3 with a suffix of 6 bytes")]
public void DescribeHeaderNamesOnlyTheKnownShape ( string header , string described )
{
Assert . That ( CultDocumentMessagePackSerialization . DescribeHeader ( header ) , Is . EqualTo ( described ) ) ;
}
[Test]
public void DescribeHeaderCountsASuffixInBytes ( )
{
const string suffix = ".directory-content-addressed-pages" ;
var header = "cultcache.store.v6" + suffix ;
Assert . That ( CultDocumentMessagePackSerialization . DescribeHeader ( header ) ,
Is . EqualTo ( $"cultcache.store.v6 with a suffix of {System.Text.Encoding.UTF8.GetByteCount(suffix)} bytes" ) ) ;
}
[Test]
public void DirectoryStoreNamesASuffixedNewerStampByItsLength ( )
{
var root = Path . Combine ( Path . GetTempPath ( ) , $"cultlib-suffix-{Guid.NewGuid():N}" ) ;
var manifest = Path . Combine ( root , "store.cc" ) ;
Directory . CreateDirectory ( root ) ;
try
{
File . WriteAllBytes ( manifest , CultDocumentMessagePackSerialization . SerializeSnapshot ( new CultPersistedStoreSnapshot
{
FormatVersion = "cultcache.store.v6.directory-content-addressed-pages" ,
SchemaCatalog = Array . Empty < CultSchemaCatalogEntry > ( ) ,
Records = Array . Empty < CultPersistedRecord > ( )
} ) ) ;
var refusal = Assert . Throws < CultStoreUnreadableException > ( ( ) = > new DirectoryMessagePackBackingStore ( manifest ) . PullAll ( ) ) ! ;
2026-10-22 23:57:51 +02:00
var suffixBytes = System . Text . Encoding . UTF8 . GetByteCount ( ".directory-content-addressed-pages" ) ;
Assert . That ( refusal . Message , Does . Contain ( $" is cultcache.store.v6 with a suffix of {suffixBytes} bytes;" ) ) ;
2026-10-22 23:53:10 +02:00
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
// An array with no stamp parses to the default v1, which no directory reads: the manifest is refused, and the pages that
// belong to the store beside it are neither rewritten nor deleted.
[Test]
public void DirectoryManifestThatIsAnEmptyArrayIsRefused ( )
{
var root = CopyFixture ( "directory-v4" ) ;
try
{
var manifest = Path . Combine ( root , "store.cc" ) ;
var emptyArray = new byte [ ] { 0x90 } ;
File . WriteAllBytes ( manifest , emptyArray ) ;
var pages = Directory . GetFiles ( root , "*" , SearchOption . AllDirectories )
. Where ( file = > file ! = manifest )
. ToDictionary ( file = > file , File . ReadAllBytes ) ;
Assert . That ( pages , Is . Not . Empty , "the fixture's pages exist" ) ;
var refusal = Assert . Throws < CultStoreUnreadableException > ( ( ) = > new DirectoryMessagePackBackingStore ( manifest ) . PullAll ( ) ) ! ;
Assert . That ( refusal . Path , Is . EqualTo ( manifest ) ) ;
Assert . That ( File . ReadAllBytes ( manifest ) , Is . EqualTo ( emptyArray ) ) ;
Assert . That ( Directory . GetFiles ( root , "*" , SearchOption . AllDirectories ) . Where ( file = > file ! = manifest ) , Is . EquivalentTo ( pages . Keys ) ) ;
foreach ( var page in pages )
Assert . That ( File . ReadAllBytes ( page . Key ) , Is . EqualTo ( page . Value ) , page . Key ) ;
}
finally
{
if ( Directory . Exists ( root ) ) Directory . Delete ( root , recursive : true ) ;
}
}
2026-09-13 17:17:14 +02:00
}
}