Readability manifest: legacy-catalog c0 rows and directory-at-path, walked by all four runtimes #114

Merged
libby merged 9 commits from walk-rows into main 2026-11-11 17:25:05 +00:00
6 changed files with 114 additions and 25 deletions

View file

@ -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()

View file

@ -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");

View file

@ -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,

View file

@ -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<byte>();
}
// 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<IOException>() : Throws.TypeOf<CultStoreUnreadableException>();
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<CultStoreUnreadableException>().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path));
{
Assert.That(Open, vector == DirectoryRow ? Refusal(vector) : Throws.TypeOf<CultStoreUnreadableException>().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<CultStoreUnreadableException>());
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<CultStoreUnreadableException>());
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));
}

View file

@ -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<byte>();
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<CultStoreUnreadableException>(), 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<IOException>(), name);
else
Assert.That(() => write(path), Throws.TypeOf<CultStoreUnreadableException>(), 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);
}

View file

@ -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