diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 80abbd4f..a8c1dbe7 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,6 +1602,11 @@ 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() + 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() 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"); 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, 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); } diff --git a/tests/vectors/document-variants-c2a/readability/manifest.txt b/tests/vectors/document-variants-c2a/readability/manifest.txt index 6d7af19d..eda1783a 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 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 @@ -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