From 11c611d4358b084a63978ec8c8b6d022fc45f92d Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 16:26:53 +0000 Subject: [PATCH 1/6] readability manifest: three legacy-catalog c0 rows and a directory-at-path row (red until each walker learns them) Co-Authored-By: Claude Sonnet 5.5 --- tests/vectors/document-variants-c2a/readability/manifest.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/vectors/document-variants-c2a/readability/manifest.txt b/tests/vectors/document-variants-c2a/readability/manifest.txt index 6d7af19d..ba9f7f66 100644 --- a/tests/vectors/document-variants-c2a/readability/manifest.txt +++ b/tests/vectors/document-variants-c2a/readability/manifest.txt @@ -1,6 +1,7 @@ # Whether each runtime's own reader opens the file. A flush and a commit replace a file exactly when it reads; where a runtime has a soft flush (TypeScript, Rust) it leaves a file that reads byte for byte and refuses one that does not. # A store holding two records under one key is unreadable; keys are ordinal strings (no case folding, no normalisation), so distinct spellings are distinct keys. # Columns: file, csharp, rust, typescript, python. `../v3-base.msgpack` is the one valid store. +# `@directory` is no file: the walker makes an empty directory at the store path and every runtime must refuse to open it. ../v3-base.msgpack reads reads reads reads zero-byte.bin refuses refuses refuses refuses empty-array.bin reads reads reads reads @@ -38,3 +39,7 @@ dup-key-different-payload-v3.bin refuses refuses refuses refuses distinct-keys-case-v3.bin reads reads reads reads distinct-keys-nfd-v3.bin reads reads reads reads ../../document-variants-c0/current-catalog-bad-canonical.msgpack refuses refuses refuses refuses +../../document-variants-c0/legacy-catalog-plain.msgpack refuses refuses refuses refuses +../../document-variants-c0/legacy-catalog-extra-slot.msgpack refuses refuses refuses refuses +../../document-variants-c0/legacy-catalog-trailing.msgpack refuses refuses refuses refuses +@directory refuses refuses refuses refuses -- 2.47.3 From 528bc8c225249bf1cc92a06375b26ed4fb3c8072 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 16:27:46 +0000 Subject: [PATCH 2/6] cultcache-py readability walk: learn @directory token; legacy-catalog rows pass A directory at the store path is refused with OSError (_read_store contract) and left untouched. Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-py/tests/test_cultcache.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 80abbd4f..71365acf 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -1567,15 +1567,19 @@ class CultCacheTests(unittest.TestCase): # Every vector in the folder has a manifest row. self.assertEqual( sorted(path.name for path in root.glob("*.bin")), - sorted(vector for vector, *_ in rows if not vector.startswith("..")), + sorted(vector for vector, *_ in rows if not vector.startswith(("..", "@"))), ) envelopes = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "v3-base.msgpack").pull_all() for vector, _, _, _, python in rows: - content = (root / vector).read_bytes() + # "@directory": the walker makes an empty directory at the store path; it is refused and left untouched. + content = None if vector == "@directory" else (root / vector).read_bytes() for operation in ("pull_all", "push_all", "push", "delete"): with tempfile.TemporaryDirectory() as tmp: store_path = Path(tmp) / "store.msgpack" - store_path.write_bytes(content) + if content is None: + store_path.mkdir() + else: + store_path.write_bytes(content) store = SingleFileMessagePackBackingStore(store_path) run = { "pull_all": store.pull_all, @@ -1598,11 +1602,18 @@ class CultCacheTests(unittest.TestCase): self.assertIn(envelopes[0].key, keys, f"{vector} {operation}") else: self.assertNotIn(envelopes[0].key, keys, f"{vector} {operation}") + elif content is None: + # A path that cannot be reached as a file stays an OSError (_read_store): not a store that failed to decode. + with self.assertRaises(OSError, msg=f"{vector} {operation}"): + run() else: with self.assertRaises(StoreUnreadableError, msg=f"{vector} {operation}") as refused: run() self.assertIsNotNone(refused.exception.__cause__, f"{vector} {operation}") - self.assertEqual(store_path.read_bytes(), content, f"{vector} {operation} rewrote a file it cannot read") + if content is None: + self.assertTrue(store_path.is_dir() and not any(store_path.iterdir()), f"{vector} {operation} touched the directory") + else: + self.assertEqual(store_path.read_bytes(), content, f"{vector} {operation} rewrote a file it cannot read") @staticmethod def _rec(type: str, key: str, second: int = 0) -> CultCacheEnvelope: -- 2.47.3 From a401d5454e31bf35a0b1951f08d25b329f251958 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 16:27:57 +0000 Subject: [PATCH 3/6] cultcache-rs readability walk: teach @directory; the three legacy-catalog c0 rows pass unchanged Rust refuses all four new rows with no source change. Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-rs/src/lib.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 868589a1..0c872d91 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -6140,16 +6140,22 @@ mod tests { for line in manifest.lines().filter(|line| !line.is_empty() && !line.starts_with('#')) { let cells: Vec<&str> = line.split_whitespace().collect(); let (vector, reads) = (cells[0], cells[2] == "reads"); - if !vector.starts_with("..") { + if !vector.starts_with("..") && !vector.starts_with('@') { listed.push(vector.to_string()); } - let bytes = std::fs::read(root.join(vector))?; + // `@directory`: the walker puts an empty directory at the store path; it is refused and left untouched. + let directory = vector == "@directory"; + let bytes = if directory { Vec::new() } else { std::fs::read(root.join(vector))? }; // A v3 store keeps its marker through a rewrite; every other file that reads is written v1. let header = if vector.contains("v3") { "cultcache.store.v3" } else { STORE_FORMAT_V1 }; for operation in ["open", "soft_push_all", "push_all", "push"] { let temp = tempfile::tempdir()?; let path = temp.path().join("store.msgpack"); - std::fs::write(&path, &bytes)?; + if directory { + std::fs::create_dir(&path)?; + } else { + std::fs::write(&path, &bytes)?; + } let mut store = SingleFileMessagePackBackingStore::new(&path); let outcome = match operation { "open" => store.pull_all().map(|_| ()), @@ -6159,6 +6165,10 @@ mod tests { }; let what = format!("{vector} {operation}"); assert_eq!(outcome.is_ok(), reads, "{what}: {outcome:?}"); + if directory { + assert!(path.is_dir() && std::fs::read_dir(&path)?.next().is_none(), "{what} touched the directory"); + continue; + } if operation == "soft_push_all" && reads { // A soft flush leaves a store it reads byte for byte. assert_eq!(std::fs::read(&path)?, bytes, "{what} rewrote a store it reads"); -- 2.47.3 From 619877f0a3791f320ae64bf505c4a04d47e564c4 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 16:28:09 +0000 Subject: [PATCH 4/6] cultcache-ts: readability walk learns @directory and the legacy-catalog c0 rows Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-ts/test/cult-cache.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 2675545e..2f40358f 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -2582,17 +2582,24 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", .filter((line) => line.length > 0 && !line.startsWith("#")) .map((line) => line.split(/\s+/u)); // Every vector in the folder has a manifest row. - const listed = rows.map(([vector]) => vector!).filter((vector) => !vector.startsWith("..")).sort(); + const listed = rows.map(([vector]) => vector!).filter((vector) => !vector.startsWith("..") && !vector.startsWith("@")).sort(); assert.deepEqual((await readdir(root)).filter((name) => name.endsWith(".bin")).sort(), listed); const dir = await mkdtemp(join(tmpdir(), "cultcache-readability-")); const seed = await copyVector(dir, join(c2aVectors, "v3-base.msgpack"), "seed.msgpack"); const envelopes = await new SingleFileMessagePackBackingStore(seed).pullAll(); for (const [vector, , , typescript] of rows) { const reads = typescript === "reads"; - const bytes = await readFile(join(root, vector)); + // "@directory": the walker makes an empty directory at the store path; the runtime refuses to open or replace it and leaves it be. + const isDirectory = vector === "@directory"; + const bytes = isDirectory ? Buffer.alloc(0) : await readFile(join(root, vector)); for (const operation of ["pullAll", "softPushAll", "pushAll", "push", "delete"] as const) { const file = join(dir, `${vector.replace(/[^a-z0-9.-]/giu, "_")}.${operation}.msgpack`); - await writeFile(file, bytes); + if (isDirectory) { + assert.equal(typescript, "refuses", "a directory is never a store"); + await mkdir(file); + } else { + await writeFile(file, bytes); + } const store = new SingleFileMessagePackBackingStore(file); const run = () => operation === "pullAll" @@ -2627,6 +2634,10 @@ test("SingleFileMessagePackBackingStore replaces a file exactly when it reads", } else { assert.ok(!keys.includes(envelopes[0]!.key), `${vector} ${operation}`); } + } else if (isDirectory) { + await assert.rejects(run, `${vector} ${operation}`); + assert.ok((await lstat(file)).isDirectory(), `${vector} ${operation} replaced the directory`); + assert.deepEqual(await readdir(file), [], `${vector} ${operation} wrote into the directory`); } else { await assert.rejects( run, -- 2.47.3 From 63a5f65fe66a44221dc71194a818a3d767164654 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 16:59:49 +0000 Subject: [PATCH 5/6] C# readability walks learn the @directory row; legacy-catalog rows pass as written Co-Authored-By: Claude Sonnet 5.5 --- .../StoreReadabilityTests.cs | 57 +++++++++++++++---- .../CultMeshSingleFileReadabilityTests.cs | 29 ++++++++-- 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs index 3ee64e07..55406948 100644 --- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs +++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs @@ -7,6 +7,7 @@ using System.Linq; using GameCult.Caching.MessagePack; using MessagePack; using NUnit.Framework; +using NUnit.Framework.Constraints; using static GameCult.Caching.Tests.ElementIdTests; namespace GameCult.Caching.Tests @@ -61,6 +62,39 @@ namespace GameCult.Caching.Tests .Select(line => line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) .Select(cells => new TestCaseData(cells[0], cells[CSharp] == "reads").SetName($"{cells[0]} {cells[CSharp]}")); + // The row `@directory` has no bytes: the walker makes an empty directory at the store path, and a runtime must refuse it untouched. + private const string DirectoryRow = "@directory"; + + private static byte[] Place(string path, string vector) + { + if (vector != DirectoryRow) + { + var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); + File.WriteAllBytes(path, bytes); + return bytes; + } + + if (File.Exists(path)) + File.Delete(path); + Directory.CreateDirectory(path); + return Array.Empty(); + } + + // A directory at the store path is refused as IOException ("not a store file", CultCache.cs): not a store that is unreadable. + private static IResolveConstraint Refusal(string vector) => + vector == DirectoryRow ? Throws.TypeOf() : Throws.TypeOf(); + + private static void AssertUntouched(string path, string vector, byte[] bytes, string message) + { + if (vector == DirectoryRow) + { + Assert.That(Directory.Exists(path), message); + Assert.That(Directory.EnumerateFileSystemEntries(path), Is.Empty, message); + } + else + Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), message); + } + private static bool HoldsAVariant(byte[] bytes) => CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes).Records.Any(record => record.Variant != null); @@ -90,14 +124,17 @@ namespace GameCult.Caching.Tests public void OpenReadsExactlyTheFilesTheVectorsSayItReads(string vector, bool reads) { var path = Path.Combine(_directory, "open.cc"); - File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), vector))); + var bytes = Place(path, vector); void Open() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }).Dispose(); if (reads) Assert.DoesNotThrow(Open); else - Assert.That(Open, Throws.TypeOf().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path)); + { + Assert.That(Open, vector == DirectoryRow ? Refusal(vector) : Throws.TypeOf().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path)); + AssertUntouched(path, vector, bytes, "a store this runtime cannot open was changed by the attempt"); + } } [TestCaseSource(nameof(Vectors))] @@ -105,8 +142,7 @@ namespace GameCult.Caching.Tests { var path = Seed("flush.cc"); using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); - var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); - File.WriteAllBytes(path, bytes); + var bytes = Place(path, vector); var store = cache.BackingStores[0]; if (reads && HoldsAVariant(bytes)) @@ -126,8 +162,8 @@ namespace GameCult.Caching.Tests return; } - Assert.That(() => store.PushAll(), Throws.TypeOf()); - Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten"); + Assert.That(() => store.PushAll(), Refusal(vector)); + AssertUntouched(path, vector, bytes, "a file this runtime cannot read was rewritten"); } [TestCaseSource(nameof(Vectors))] @@ -135,8 +171,7 @@ namespace GameCult.Caching.Tests { var path = Seed("commit.cc"); using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); - var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); - File.WriteAllBytes(path, bytes); + var bytes = Place(path, vector); if (reads && HoldsAVariant(bytes)) { @@ -153,8 +188,8 @@ namespace GameCult.Caching.Tests return; } - Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf()); - Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten"); + Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Refusal(vector)); + AssertUntouched(path, vector, bytes, "a file this runtime cannot read was rewritten"); } private string DirectoryStore(string name) @@ -679,7 +714,7 @@ namespace GameCult.Caching.Tests [Test] public void EveryVectorInTheFolderHasAManifestRow() { - var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal); + var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal) && name != DirectoryRow).OrderBy(name => name, StringComparer.Ordinal); var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal); Assert.That(rows, Is.EqualTo(files)); } diff --git a/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs index 556d785d..bf2c0e4c 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs @@ -16,6 +16,8 @@ namespace GameCult.Mesh.Tests; public sealed class CultMeshSingleFileReadabilityTests { private const int CSharp = 1; + // The row `@directory` has no bytes: the walker makes an empty directory at the store path, and the write must refuse it untouched. + private const string DirectoryRow = "@directory"; private string _root = null!; [SetUp] @@ -67,8 +69,15 @@ public sealed class CultMeshSingleFileReadabilityTests foreach (var (name, write) in writes) { var path = Path.Combine(_root, name.Replace(' ', '-') + ".cc"); - var bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); - File.WriteAllBytes(path, bytes); + var isDirectory = vector == DirectoryRow; + var bytes = Array.Empty(); + if (isDirectory) + Directory.CreateDirectory(path); + else + { + bytes = File.ReadAllBytes(Path.Combine(VectorRoot(), vector)); + File.WriteAllBytes(path, bytes); + } if (reads) { @@ -96,8 +105,18 @@ public sealed class CultMeshSingleFileReadabilityTests continue; } - Assert.That(() => write(path), Throws.TypeOf(), name); - File.ReadAllBytes(path).Should().Equal(bytes, name + " rewrote a file this runtime cannot read"); + // A directory at the path is refused as IOException ("not a store file"), not as an unreadable store. + if (isDirectory) + Assert.That(() => write(path), Throws.TypeOf(), name); + else + Assert.That(() => write(path), Throws.TypeOf(), name); + if (isDirectory) + { + Directory.Exists(path).Should().BeTrue(name + " replaced a directory"); + Directory.EnumerateFileSystemEntries(path).Should().BeEmpty(name + " wrote into a directory"); + } + else + File.ReadAllBytes(path).Should().Equal(bytes, name + " rewrote a file this runtime cannot read"); } } @@ -115,7 +134,7 @@ public sealed class CultMeshSingleFileReadabilityTests [Test] public void EveryVectorInTheFolderHasAManifestRow() { - var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal); + var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal) && name != DirectoryRow).OrderBy(name => name, StringComparer.Ordinal); var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal); rows.Should().Equal(files); } -- 2.47.3 From 12e938d23cb68c717d0bce7dd71fe78b23967e3b Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 17:25:03 +0000 Subject: [PATCH 6/6] Python @directory walk asserts the directory stays empty; manifest comment names the write paths Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-py/tests/test_cultcache.py | 6 ++---- .../vectors/document-variants-c2a/readability/manifest.txt | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 71365acf..a8c1dbe7 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -1606,14 +1606,12 @@ class CultCacheTests(unittest.TestCase): # A path that cannot be reached as a file stays an OSError (_read_store): not a store that failed to decode. with self.assertRaises(OSError, msg=f"{vector} {operation}"): run() + self.assertTrue(store_path.is_dir() and not any(store_path.iterdir()), f"{vector} {operation} touched the directory") else: with self.assertRaises(StoreUnreadableError, msg=f"{vector} {operation}") as refused: run() self.assertIsNotNone(refused.exception.__cause__, f"{vector} {operation}") - if content is None: - self.assertTrue(store_path.is_dir() and not any(store_path.iterdir()), f"{vector} {operation} touched the directory") - else: - self.assertEqual(store_path.read_bytes(), content, f"{vector} {operation} rewrote a file it cannot read") + self.assertEqual(store_path.read_bytes(), content, f"{vector} {operation} rewrote a file it cannot read") @staticmethod def _rec(type: str, key: str, second: int = 0) -> CultCacheEnvelope: diff --git a/tests/vectors/document-variants-c2a/readability/manifest.txt b/tests/vectors/document-variants-c2a/readability/manifest.txt index ba9f7f66..eda1783a 100644 --- a/tests/vectors/document-variants-c2a/readability/manifest.txt +++ b/tests/vectors/document-variants-c2a/readability/manifest.txt @@ -1,7 +1,7 @@ # Whether each runtime's own reader opens the file. A flush and a commit replace a file exactly when it reads; where a runtime has a soft flush (TypeScript, Rust) it leaves a file that reads byte for byte and refuses one that does not. # A store holding two records under one key is unreadable; keys are ordinal strings (no case folding, no normalisation), so distinct spellings are distinct keys. # Columns: file, csharp, rust, typescript, python. `../v3-base.msgpack` is the one valid store. -# `@directory` is no file: the walker makes an empty directory at the store path and every runtime must refuse to open it. +# `@directory` is no file: the walker makes an empty directory at the store path and every runtime must refuse to open it and to write over it (flush, commit, delete), leaving the directory empty. ../v3-base.msgpack reads reads reads reads zero-byte.bin refuses refuses refuses refuses empty-array.bin reads reads reads reads -- 2.47.3