diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index 4ea8f845..240b22d8 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -13,6 +13,8 @@ Cross-runtime parity has three layers: - Wire parity: shared CultCache file/state contracts, schema-v0 MessagePack documents, transport profiles, and RUDP packets must cross runtime boundaries without translation folklore. + Every store reader refuses, by name, a header or record slot it does not + understand (vectors: `tests/vectors/document-variants-c0`). - Feature parity: each runtime must expose the CultCache/CultNet/CultMesh surfaces needed for its intended role, with unsupported production-server ownership called out plainly. diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 697882e0..0980f743 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -14,6 +14,8 @@ from .backing_store import ( ) STORE_FORMAT_VERSION = "cultcache.store.v1" +_STORE_FORMAT_PREFIX = "cultcache.store." +_PERSISTED_RECORD_SLOTS = 4 class JsonLinesBackingStore: @@ -153,7 +155,14 @@ def _encode_v1_snapshot(envelopes: list[CultCacheEnvelope]) -> list[Any]: def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None: - if not isinstance(decoded, list) or not decoded or decoded[0] != STORE_FORMAT_VERSION: + if not isinstance(decoded, list) or not decoded: + return None + if isinstance(decoded[0], str) and decoded[0].startswith(_STORE_FORMAT_PREFIX) and decoded[0] != STORE_FORMAT_VERSION: + raise ValueError( + f"CultCache store format {decoded[0]!r} is not readable; this runtime reads {STORE_FORMAT_VERSION!r} only. " + "The store needs a runtime that resolves document variants." + ) + if decoded[0] != STORE_FORMAT_VERSION: return None if len(decoded) < 3 or not isinstance(decoded[1], list) or not isinstance(decoded[2], list): raise ValueError("CultCache v1 snapshot must contain a schema catalog and record array") @@ -169,7 +178,12 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None: for raw_record in decoded[2]: if not isinstance(raw_record, list) or len(raw_record) < 4: raise ValueError("CultCache persisted records must be MessagePack arrays") - key, schema_id, stored_at, payload = raw_record[:4] + if len(raw_record) > _PERSISTED_RECORD_SLOTS: + raise ValueError( + f"CultCache record {raw_record[0]!r} (schema {raw_record[1]!r}) has {len(raw_record)} slots; " + f"this runtime reads {_PERSISTED_RECORD_SLOTS}. The store needs a runtime that resolves document variants." + ) + key, schema_id, stored_at, payload = raw_record if not isinstance(key, str) or not key: raise ValueError("CultCache persisted records must declare a key") if not isinstance(schema_id, str) or not schema_id: diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index ac81cc6d..b4168690 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -802,5 +802,39 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(cache.get_required_envelope(document, "item:one").key, "item:one") + # Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. + _VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c0" + _ITEM_SCHEMA_ID = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788" + + def _pull_vector(self, name: str) -> list[CultCacheEnvelope]: + with tempfile.TemporaryDirectory() as tmp: + store_path = Path(tmp) / "store.msgpack" + store_path.write_bytes((self._VECTORS / name).read_bytes()) + return SingleFileMessagePackBackingStore(store_path).pull_all() + + def test_single_file_refuses_unknown_header_by_name(self) -> None: + with self.assertRaises(ValueError) as caught: + self._pull_vector("unknown-header.msgpack") + self.assertIn("cultcache.store.v9", str(caught.exception)) + + def test_single_file_refuses_extra_record_slot_naming_the_record(self) -> None: + with self.assertRaises(ValueError) as caught: + self._pull_vector("extra-slot-full-payload.msgpack") + self.assertIn("item:anvil", str(caught.exception)) + self.assertIn(self._ITEM_SCHEMA_ID, str(caught.exception)) + + def test_single_file_refuses_variant_store_by_version_or_record(self) -> None: + with self.assertRaises(ValueError) as caught: + self._pull_vector("variant-v2.msgpack") + message = str(caught.exception) + self.assertTrue("cultcache.store.v2" in message or "item:anvil-big" in message, message) + + def test_v1_store_written_at_the_base_commit_still_reads_byte_for_byte(self) -> None: + envelopes = self._pull_vector("v1-base.msgpack") + self.assertEqual([(e.key, e.type) for e in envelopes], [("alpha", "vectors.item"), ("beta", "vectors.item")]) + self.assertEqual(envelopes[0].payload, b"\x92\xa5alpha\x01") + self.assertEqual(envelopes[1].payload, b"\x92\xa4beta\x02") + + if __name__ == "__main__": unittest.main() diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index ed9cae32..e48ebad8 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -271,7 +271,7 @@ struct PersistedSchemaCatalogMember( Option, ); -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, serde::Serialize)] struct PersistedRecord( String, String, @@ -279,6 +279,67 @@ struct PersistedRecord( #[serde(with = "serde_bytes")] Vec, ); +impl<'de> serde::Deserialize<'de> for PersistedRecord { + /// Reads the four v1 slots and refuses a fifth, naming the record. A record with more + /// slots was written by a runtime that resolves document variants. + fn deserialize>(deserializer: D) -> std::result::Result { + use serde::de::{Error, IgnoredAny, SeqAccess, Visitor}; + + struct RecordVisitor; + impl<'de> Visitor<'de> for RecordVisitor { + type Value = PersistedRecord; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a CultCache persisted record of 4 slots") + } + + fn visit_seq>(self, mut seq: A) -> std::result::Result { + let key: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?; + let schema_id: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?; + let stored_at: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(2, &self))?; + let payload: serde_bytes::ByteBuf = + seq.next_element()?.ok_or_else(|| A::Error::invalid_length(3, &self))?; + if seq.next_element::()?.is_some() { + return Err(A::Error::custom(format!( + "CultCache record {key:?} (schema {schema_id:?}) has more than 4 slots; this runtime reads 4. The store needs a runtime that resolves document variants." + ))); + } + Ok(PersistedRecord(key, schema_id, stored_at, payload.into_vec())) + } + } + + deserializer.deserialize_seq(RecordVisitor) + } +} + +/// The store header string, or `None` when the bytes are not a snapshot-shaped array +/// (the legacy envelope array starts with a map). +fn store_header(bytes: &[u8]) -> Option { + struct Header(String); + impl<'de> serde::Deserialize<'de> for Header { + fn deserialize>(deserializer: D) -> std::result::Result { + struct HeaderVisitor; + impl<'de> serde::de::Visitor<'de> for HeaderVisitor { + type Value = Header; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an array whose first slot is the store header") + } + + fn visit_seq>(self, mut seq: A) -> std::result::Result { + let header: String = seq + .next_element()? + .ok_or_else(|| ::invalid_length(0, &self))?; + while seq.next_element::()?.is_some() {} + Ok(Header(header)) + } + } + deserializer.deserialize_seq(HeaderVisitor) + } + } + rmp_serde::from_slice::
(bytes).ok().map(|header| header.0) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct PushAllOptions { pub soft: bool, @@ -658,9 +719,11 @@ impl SingleFileMessagePackBackingStore { if bytes.is_empty() { return Ok(Vec::new()); } - decode_store_snapshot(&bytes) - .or_else(|_| rmp_serde::from_slice(&bytes)) - .with_context(|| format!("failed to decode MessagePack {}", self.path.display())) + match store_header(&bytes) { + Some(header) if header.starts_with("cultcache.store.") => decode_store_snapshot(&bytes), + _ => rmp_serde::from_slice(&bytes).map_err(anyhow::Error::from), + } + .map_err(|error| anyhow!("failed to decode MessagePack {}: {error:#}", self.path.display())) } /// Reads one filesystem snapshot without creating or opening the sibling @@ -2518,11 +2581,15 @@ fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> Result Result> { + if let Some(header) = store_header(bytes) { + if header != "cultcache.store.v1" { + return Err(anyhow!( + "CultCache store format {header:?} is not readable; this runtime reads \"cultcache.store.v1\" only. The store needs a runtime that resolves document variants." + )); + } + } let snapshot: PersistedStoreSnapshot = rmp_serde::from_slice(bytes).context("failed to decode CultCache v1 snapshot")?; - if snapshot.0 != "cultcache.store.v1" { - return Err(anyhow!("unsupported CultCache snapshot {}", snapshot.0)); - } let catalog = snapshot .1 @@ -4573,4 +4640,52 @@ mod tests { assert_eq!(store.pull_all_read_only_snapshot()?, vec![admitted]); Ok(()) } + + // Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. + const ITEM_SCHEMA_ID: &str = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"; + + fn pull_vector(name: &str) -> Result> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + let vector = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/vectors/document-variants-c0") + .join(name); + std::fs::copy(vector, &path)?; + SingleFileMessagePackBackingStore::new(&path).pull_all() + } + + fn refusal(name: &str) -> String { + format!("{:#}", pull_vector(name).unwrap_err()) + } + + #[test] + fn single_file_refuses_unknown_header_by_name() { + assert!(refusal("unknown-header.msgpack").contains("cultcache.store.v9")); + } + + #[test] + fn single_file_refuses_extra_record_slot_naming_the_record() { + let message = refusal("extra-slot-full-payload.msgpack"); + assert!(message.contains("item:anvil"), "{message}"); + assert!(message.contains(ITEM_SCHEMA_ID), "{message}"); + } + + #[test] + fn single_file_refuses_variant_store_by_version_or_record() { + let message = refusal("variant-v2.msgpack"); + assert!( + message.contains("cultcache.store.v2") || message.contains("item:anvil-big"), + "{message}" + ); + } + + #[test] + fn v1_store_written_at_the_base_commit_still_reads_byte_for_byte() -> Result<()> { + let envelopes = pull_vector("v1-base.msgpack")?; + let rows: Vec<_> = envelopes.iter().map(|e| (e.key.as_str(), e.r#type.as_str())).collect(); + assert_eq!(rows, vec![("alpha", "vectors.item"), ("beta", "vectors.item")]); + assert_eq!(envelopes[0].payload, b"\x92\xa5alpha\x01"); + assert_eq!(envelopes[1].payload, b"\x92\xa4beta\x02"); + Ok(()) + } } diff --git a/packages/cultcache-ts/src/cult-cache-inspector.ts b/packages/cultcache-ts/src/cult-cache-inspector.ts index 1301fe47..54e75700 100644 --- a/packages/cultcache-ts/src/cult-cache-inspector.ts +++ b/packages/cultcache-ts/src/cult-cache-inspector.ts @@ -1,5 +1,7 @@ import { decode } from "@msgpack/msgpack"; +import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format"; + export interface CultCacheInspection { filePath: string; fileSizeBytes: number; @@ -39,8 +41,6 @@ export interface InspectedRecord { payloadDecodeError?: string; } -const STORE_FORMAT_VERSION = "cultcache.store.v1"; - export function inspectCultCacheBytes( filePath: string, bytes: Uint8Array, @@ -197,6 +197,7 @@ function decodeV1Record(value: unknown, catalogBySchemaId: Map { return typeof value === "object" && value !== null; } diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 4d2bb4f9..b7d9684c 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path"; import { decode, encode } from "@msgpack/msgpack"; import { z } from "zod"; +import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format"; import type { CacheBackingStore, CultCacheEnvelope, @@ -12,8 +13,6 @@ import type { PushAllOptions, } from "./types"; -const STORE_FORMAT_VERSION = "cultcache.store.v1"; - const envelopeSchema = z.object({ key: z.string().min(1), type: z.string().min(1), @@ -285,7 +284,7 @@ function encodeCatalogMember(member: CultCacheSchemaCatalogMember): unknown[] { } function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined { - if (!Array.isArray(decoded) || decoded.length === 0 || decoded[0] !== STORE_FORMAT_VERSION) { + if (!isV1Snapshot(decoded)) { return undefined; } @@ -436,6 +435,7 @@ function decodeRecord(value: unknown): PersistedRecord { throw new Error("CultCache persisted records must be MessagePack arrays."); } + requireV1RecordSlots(value); const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value; if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) { throw new Error("CultCache persisted records must declare key, schemaId, and storedAt."); diff --git a/packages/cultcache-ts/src/store-format.ts b/packages/cultcache-ts/src/store-format.ts new file mode 100644 index 00000000..02de60e7 --- /dev/null +++ b/packages/cultcache-ts/src/store-format.ts @@ -0,0 +1,33 @@ +export const STORE_FORMAT_VERSION = "cultcache.store.v1"; + +const STORE_FORMAT_PREFIX = "cultcache.store."; +const PERSISTED_RECORD_SLOTS = 4; + +/** + * True for a v1 snapshot, false for anything that is not a CultCache store header (the legacy + * envelope array). Any other `cultcache.store.*` header is refused by name. + */ +export function isV1Snapshot(decoded: unknown): decoded is unknown[] { + if (!Array.isArray(decoded) || typeof decoded[0] !== "string" || !decoded[0].startsWith(STORE_FORMAT_PREFIX)) { + return false; + } + + if (decoded[0] !== STORE_FORMAT_VERSION) { + throw new Error( + `CultCache store format "${decoded[0]}" is not readable; this runtime reads "${STORE_FORMAT_VERSION}" only. ` + + "The store needs a runtime that resolves document variants.", + ); + } + + return true; +} + +/** Refuses a persisted record with more slots than v1 defines, naming its key and schema id. */ +export function requireV1RecordSlots(record: unknown[]): void { + if (record.length > PERSISTED_RECORD_SLOTS) { + throw new Error( + `CultCache record "${String(record[0])}" (schema "${String(record[1])}") has ${record.length} slots; ` + + `this runtime reads ${PERSISTED_RECORD_SLOTS}. The store needs a runtime that resolves document variants.`, + ); + } +} diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 5932b2f8..a743ba00 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -1413,3 +1413,48 @@ async function runJsonCommand( return JSON.parse(trimmed.split(/\r?\n/).at(-1) as string); } + +// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. +const variantVectors = resolve(cultLibRoot, "tests", "vectors", "document-variants-c0"); +const itemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"; + +async function pullVector(name: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "cultcache-vector-")); + const file = join(dir, "store.msgpack"); + await writeFile(file, await readFile(join(variantVectors, name))); + return new SingleFileMessagePackBackingStore(file).pullAll(); +} + +test("SingleFileMessagePackBackingStore refuses an unknown store header by name", async () => { + await assert.rejects(() => pullVector("unknown-header.msgpack"), /cultcache\.store\.v9/u); +}); + +test("SingleFileMessagePackBackingStore refuses an extra record slot naming the record", async () => { + await assert.rejects( + () => pullVector("extra-slot-full-payload.msgpack"), + (error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId), + ); +}); + +test("SingleFileMessagePackBackingStore refuses a variant store by version or record", async () => { + await assert.rejects( + () => pullVector("variant-v2.msgpack"), + (error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("item:anvil-big"), + ); +}); + +test("CultCache inspector refuses the same vectors by name", async () => { + const inspect = async (name: string) => inspectCultCacheBytes(name, await readFile(join(variantVectors, name))); + await assert.rejects(() => inspect("unknown-header.msgpack"), /cultcache\.store\.v9/u); + await assert.rejects( + () => inspect("extra-slot-full-payload.msgpack"), + (error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId), + ); +}); + +test("a v1 store written at the base commit still reads byte for byte", async () => { + const envelopes = await pullVector("v1-base.msgpack"); + assert.deepEqual(envelopes.map((entry) => [entry.key, entry.type]), [["alpha", "vectors.item"], ["beta", "vectors.item"]]); + assert.deepEqual([...envelopes[0]!.payload], [0x92, 0xa5, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x01]); + assert.deepEqual([...envelopes[1]!.payload], [0x92, 0xa4, 0x62, 0x65, 0x74, 0x61, 0x02]); +}); diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 1e99125e..bb62d2ed 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -30,6 +30,7 @@ public sealed class CultDocumentResolver : IFormatterResolver public static class CultDocumentMessagePackSerialization { + private const string SingleFileFormatVersion = "cultcache.store.v1"; private const int PersistedRecordFieldCount = 4; private const int SchemaCatalogEntryFieldCount = 7; private const int SchemaCatalogMemberFieldCount = 8; @@ -139,7 +140,8 @@ public static class CultDocumentMessagePackSerialization if (fieldCount > 0) { - snapshot.FormatVersion = reader.ReadString() ?? "cultcache.store.v1"; + snapshot.FormatVersion = reader.ReadString() + ?? throw new NotSupportedException("Store snapshot declares no format version; this runtime reads " + SingleFileFormatVersion + " only."); } if (fieldCount > 1) @@ -170,6 +172,17 @@ public static class CultDocumentMessagePackSerialization return snapshot; } + /// Refuses a single-file snapshot this runtime cannot read, naming the format it found. + public static void RequireSingleFileFormat(CultPersistedStoreSnapshot snapshot) + { + if (!string.Equals(snapshot.FormatVersion, SingleFileFormatVersion, StringComparison.Ordinal)) + { + throw new NotSupportedException( + $"Store format {snapshot.FormatVersion} is not readable; this runtime reads {SingleFileFormatVersion} only. " + + "The store needs a runtime that resolves document variants."); + } + } + private static void WritePersistedRecord(ref MessagePackWriter writer, CultPersistedRecord record) { writer.WriteArrayHeader(PersistedRecordFieldCount); @@ -179,7 +192,7 @@ public static class CultDocumentMessagePackSerialization writer.Write(record.Payload); } - private static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader) + public static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader) { var fieldCount = reader.ReadArrayHeader(); var record = new CultPersistedRecord(); @@ -204,9 +217,11 @@ public static class CultDocumentMessagePackSerialization record.Payload = reader.ReadBytes()?.ToArray() ?? Array.Empty(); } - for (var index = PersistedRecordFieldCount; index < fieldCount; index++) + if (fieldCount > PersistedRecordFieldCount) { - reader.Skip(); + throw new NotSupportedException( + $"Record '{record.Key}' (schema '{record.SchemaId}') has {fieldCount} slots; this runtime reads {PersistedRecordFieldCount}. " + + "The store needs a runtime that resolves document variants."); } return record; @@ -371,7 +386,9 @@ public class SingleFileMessagePackBackingStore : SingleFileBackingStore protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data) { - return CultDocumentMessagePackSerialization.DeserializeSnapshot(data); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(data); + CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot); + return snapshot; } protected override byte[] SerializePayload(object document) diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index e8231c10..4511f3c5 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -212,6 +212,11 @@ Example: The payload is the domain object only. It does not repeat the key, schema id, or stored timestamp. +Under `cultcache.store.v1` a record has exactly these four slots. A reader refuses any +other `cultcache.store.*` header and any record with more than four slots, naming the +version or the record's key and schema id. It never skips a slot it does not +understand. + ## Canonical Semantic Schema Hash `schemaId` should be derived from a canonical semantic schema hash. diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs index 5e2295df..46bfb47e 100644 --- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs +++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs @@ -236,14 +236,18 @@ namespace GameCult.Mesh private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path) { var bytes = File.ReadAllBytes(path); + CultPersistedStoreSnapshot snapshot; try { - return CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); + snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); } catch (Exception ex) when (ex is MessagePackSerializationException or InvalidOperationException) { - return ReadLegacySingleFileSnapshot(bytes); + snapshot = ReadLegacySingleFileSnapshot(bytes); } + + CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot); + return snapshot; } private static CultPersistedStoreSnapshot ReadLegacySingleFileSnapshot(byte[] bytes) @@ -252,7 +256,8 @@ namespace GameCult.Mesh var fieldCount = reader.ReadArrayHeader(); var snapshot = new CultPersistedStoreSnapshot(); if (fieldCount > 0) - snapshot.FormatVersion = reader.ReadString() ?? "cultcache.store.v1"; + snapshot.FormatVersion = reader.ReadString() + ?? throw new NotSupportedException("Store snapshot declares no format version."); if (fieldCount > 1) { @@ -267,7 +272,7 @@ namespace GameCult.Mesh var recordCount = reader.ReadArrayHeader(); snapshot.Records = new CultPersistedRecord[recordCount]; for (var index = 0; index < recordCount; index++) - snapshot.Records[index] = ReadLegacyPersistedRecord(ref reader); + snapshot.Records[index] = CultDocumentMessagePackSerialization.ReadPersistedRecord(ref reader); } for (var index = 3; index < fieldCount; index++) @@ -303,23 +308,6 @@ namespace GameCult.Mesh return entry; } - private static CultPersistedRecord ReadLegacyPersistedRecord(ref MessagePackReader reader) - { - var fieldCount = reader.ReadArrayHeader(); - var record = new CultPersistedRecord(); - if (fieldCount > 0) record.Key = reader.ReadString() ?? string.Empty; - if (fieldCount > 1) record.SchemaId = reader.ReadString() ?? string.Empty; - if (fieldCount > 2) record.StoredAt = reader.ReadString() ?? string.Empty; - if (fieldCount > 3) - { - var payload = reader.ReadBytes(); - record.Payload = payload.HasValue ? payload.Value.ToArray() : Array.Empty(); - } - for (var index = 4; index < fieldCount; index++) - reader.Skip(); - return record; - } - private static bool PublishesSchema(CultSchemaCatalogEntry[] catalog, string schemaId) { return catalog.Any(entry => diff --git a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs index 6089b94e..479eb762 100644 --- a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs +++ b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs @@ -1,6 +1,7 @@ #nullable enable using System; using System.IO; +using System.Linq; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; @@ -83,6 +84,82 @@ namespace GameCult.Caching.Tests } } + // 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}."); + } + + private static string Refusal(string vector) + { + 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(); + var error = Assert.Catch(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(file))); + Assert.That(error, Is.Not.Null); + Assert.That(error!.ToString(), Does.Contain(nameof(NotSupportedException))); + return error.ToString(); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + [Test] + public void SingleFileRefusesUnknownHeaderByName() + { + Assert.That(Refusal("unknown-header.msgpack"), Does.Contain("cultcache.store.v9")); + } + + [Test] + public void SingleFileRefusesExtraRecordSlotNamingTheRecord() + { + var message = Refusal("extra-slot-full-payload.msgpack"); + Assert.That(message, Does.Contain("item:anvil")); + Assert.That(message, Does.Contain(ItemSchemaId)); + } + + [Test] + public void SingleFileRefusesVariantStoreByVersionOrRecord() + { + Assert.That(Refusal("variant-v2.msgpack"), + Does.Contain("cultcache.store.v2").Or.Contain("item:anvil-big")); + } + + [Test] + public void SnapshotWithoutAFormatVersionIsRefusedNotDefaultedToV1() + { + // [nil, [], []] + var bytes = new byte[] { 0x93, 0xc0, 0x90, 0x90 }; + + Assert.That(() => CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes), + Throws.TypeOf().With.Message.Contains("format version")); + } + + [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 })); + } + // Format strings taken from DirectoryMessagePackBackingStore.cs at 0db1fe5. [TestCase("cultcache.store.v1")] [TestCase("cultcache.store.v1.directory")] diff --git a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs new file mode 100644 index 00000000..a5be069a --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using FluentAssertions; +using GameCult.Caching; +using NUnit.Framework; + +#nullable enable + +namespace GameCult.Mesh.Tests; + +// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. +public sealed class CultMeshVariantRefusalTests +{ + 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}."); + } + + private static NotSupportedException Refusal(string vector) => + Assert.Throws(() => + CultMesh.ReadSingleFileDocumentPayload(VectorPath(vector), new CultRecordKey("item:bellows"), ItemSchemaId))!; + + [Test] + public void ReadSingleFileDocument_RefusesUnknownHeaderByName() => + Refusal("unknown-header.msgpack").Message.Should().Contain("cultcache.store.v9"); + + [Test] + public void ReadSingleFileDocument_RefusesExtraRecordSlotNamingTheRecord() + { + var message = Refusal("extra-slot-full-payload.msgpack").Message; + message.Should().Contain("item:anvil").And.Contain(ItemSchemaId); + } + + [Test] + public void ReadSingleFileDocument_RefusesExtraRecordSlotInTheLegacyCatalogLayoutToo() + { + var message = Refusal("legacy-catalog-extra-slot.msgpack").Message; + message.Should().Contain("item:anvil").And.Contain(ItemSchemaId); + } + + [Test] + public void ReadSingleFileDocument_RefusesVariantStoreByVersionOrRecord() + { + var message = Refusal("variant-v2.msgpack").Message; + (message.Contains("cultcache.store.v2") || message.Contains("item:anvil-big")).Should().BeTrue(message); + } +} diff --git a/tests/vectors/document-variants-c0/.gitattributes b/tests/vectors/document-variants-c0/.gitattributes new file mode 100644 index 00000000..8efeaf12 --- /dev/null +++ b/tests/vectors/document-variants-c0/.gitattributes @@ -0,0 +1,3 @@ +# Refusal vectors are byte-exact; never convert line endings. +* binary +*.py text diff --git a/tests/vectors/document-variants-c0/extra-slot-full-payload.msgpack b/tests/vectors/document-variants-c0/extra-slot-full-payload.msgpack new file mode 100644 index 00000000..a9bf1bd9 Binary files /dev/null and b/tests/vectors/document-variants-c0/extra-slot-full-payload.msgpack differ diff --git a/tests/vectors/document-variants-c0/generate.py b/tests/vectors/document-variants-c0/generate.py new file mode 100644 index 00000000..e3d2547d --- /dev/null +++ b/tests/vectors/document-variants-c0/generate.py @@ -0,0 +1,41 @@ +"""Generates the shared C0 refusal vectors from the pre-Cut 2 v1 store (written before any +variant code existed). Every runtime's tests read the committed bytes; nothing regenerates them +at test time. Run: python generate.py (needs msgpack). + + unknown-header.msgpack header cultcache.store.v9, otherwise the v1 store + extra-slot-full-payload.msgpack v1 header, record item:anvil gains slot 4, payload stays full + legacy-catalog-extra-slot.msgpack + v1 header, catalog entry in the older layout (contentHash at slot + 5), so CultMesh's legacy snapshot reader takes it, plus the + extra-slot record + v1-base.msgpack NOT generated here: written once by the Python runtime at CultLib + 69a21bb (SingleFileMessagePackBackingStore.push of alpha and + beta, type vectors.item, before any C0 code existed) + variant-v2.msgpack what C1 writes for a variant: header v2, record slot 4 = + [baseKey, overrides[]], empty payload (override op Set = 0, + path steps are [slot, elementId]) +""" +import pathlib + +import msgpack + +here = pathlib.Path(__file__).resolve().parent +source = here.parent.parent / "GameCult.Caching.Tests" / "Fixtures" / "pre-cut2" / "single-file-v1" / "store.msgpack" +header, catalog, records = msgpack.unpackb(source.read_bytes(), raw=False) +assert header == "cultcache.store.v1" +anvil = records[0] +assert anvil[0] == "item:anvil" + + +def write(name, snapshot): + (here / name).write_bytes(msgpack.packb(snapshot, use_bin_type=True)) + + +write("unknown-header.msgpack", ["cultcache.store.v9", catalog, records]) +write("extra-slot-full-payload.msgpack", + [header, catalog, [anvil + [["item:bellows", []]]] + records[1:]]) +variant = ["item:anvil-big", anvil[1], anvil[2], b"", ["item:anvil", [[0, [[1, ""]], "", 99]]]] +write("variant-v2.msgpack", ["cultcache.store.v2", catalog, records + [variant]]) +legacy_entry = [catalog[0][0], catalog[0][1], catalog[0][2], catalog[0][4], None, catalog[0][3], []] +write("legacy-catalog-extra-slot.msgpack", + [header, [legacy_entry], [anvil + [["item:bellows", []]]]]) diff --git a/tests/vectors/document-variants-c0/legacy-catalog-extra-slot.msgpack b/tests/vectors/document-variants-c0/legacy-catalog-extra-slot.msgpack new file mode 100644 index 00000000..7ae1a542 --- /dev/null +++ b/tests/vectors/document-variants-c0/legacy-catalog-extra-slot.msgpack @@ -0,0 +1 @@ +“²cultcache.store.v1‘—ÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788µfixtures.precut2.item¸fixtures.precut2.item.v1Úp{"schemaName":"fixtures.precut2.item","schemaVersion":"fixtures.precut2.item.v1","members":[{"slot":0,"name":"Name","type":"System.String","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":true},{"slot":1,"name":"Count","type":"System.Int32","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":false}]}ÀÙGsha256:dadf30bc920849ee89ed5d70cc73027070dd7571c5f878db1901f3abc459cc0c�‘•ªitem:anvilÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4032226+00:00Ä’¥anvil’¬item:bellows� \ No newline at end of file diff --git a/tests/vectors/document-variants-c0/unknown-header.msgpack b/tests/vectors/document-variants-c0/unknown-header.msgpack new file mode 100644 index 00000000..82f07c2c Binary files /dev/null and b/tests/vectors/document-variants-c0/unknown-header.msgpack differ diff --git a/tests/vectors/document-variants-c0/v1-base.msgpack b/tests/vectors/document-variants-c0/v1-base.msgpack new file mode 100644 index 00000000..dcdb411f --- /dev/null +++ b/tests/vectors/document-variants-c0/v1-base.msgpack @@ -0,0 +1 @@ +“²cultcache.store.v1‘—¬vectors.item¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item�’”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta \ No newline at end of file diff --git a/tests/vectors/document-variants-c0/variant-v2.msgpack b/tests/vectors/document-variants-c0/variant-v2.msgpack new file mode 100644 index 00000000..3f3efe42 Binary files /dev/null and b/tests/vectors/document-variants-c0/variant-v2.msgpack differ