Merge variants C0: every reader refuses what C1 will write, by name
C#, TypeScript, Python, Rust and CultMesh refuse an unknown cultcache.store.* header and a fifth record slot, naming the version or the key and schemaId. The silent slot-skipping and C#'s ignored header are deleted, and CultMesh's second record decoder is gone. Shared vectors are in tests/vectors/document-variants-c0/. v1 stores stay byte-identical. At base, the single-file commit's read-modify-write rewrote a v2 store as v1, which lost data; it now refuses. Soul passed it. Cross-runtime interop was run and passes. Three C# Caching tests fail on Linux at both base and tip: they are Windows share-mode tests (a FileShare.Read handle blocking delete/replace), which cannot pass on Linux. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e382bb4e20
20 changed files with 471 additions and 44 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ struct PersistedSchemaCatalogMember(
|
|||
Option<String>,
|
||||
);
|
||||
|
||||
#[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<u8>,
|
||||
);
|
||||
|
||||
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<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
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<A: SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<PersistedRecord, A::Error> {
|
||||
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::<IgnoredAny>()?.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<String> {
|
||||
struct Header(String);
|
||||
impl<'de> serde::Deserialize<'de> for Header {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
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<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Header, A::Error> {
|
||||
let header: String = seq
|
||||
.next_element()?
|
||||
.ok_or_else(|| <A::Error as serde::de::Error>::invalid_length(0, &self))?;
|
||||
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
|
||||
Ok(Header(header))
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_seq(HeaderVisitor)
|
||||
}
|
||||
}
|
||||
rmp_serde::from_slice::<Header>(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<PersistedStore
|
|||
}
|
||||
|
||||
fn decode_store_snapshot(bytes: &[u8]) -> Result<Vec<CultCacheEnvelope>> {
|
||||
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<Vec<CultCacheEnvelope>> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, Inspected
|
|||
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.");
|
||||
|
|
@ -349,10 +350,6 @@ function toJsonSafe(value: unknown): unknown {
|
|||
return value;
|
||||
}
|
||||
|
||||
function isV1Snapshot(value: unknown): value is unknown[] {
|
||||
return Array.isArray(value) && value[0] === STORE_FORMAT_VERSION;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
33
packages/cultcache-ts/src/store-format.ts
Normal file
33
packages/cultcache-ts/src/store-format.ts
Normal file
|
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CultCacheEnvelope[]> {
|
||||
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]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>Refuses a single-file snapshot this runtime cannot read, naming the format it found.</summary>
|
||||
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<byte>();
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<byte>();
|
||||
}
|
||||
for (var index = 4; index < fieldCount; index++)
|
||||
reader.Skip();
|
||||
return record;
|
||||
}
|
||||
|
||||
private static bool PublishesSchema(CultSchemaCatalogEntry[] catalog, string schemaId)
|
||||
{
|
||||
return catalog.Any(entry =>
|
||||
|
|
|
|||
|
|
@ -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<NotSupportedException>().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")]
|
||||
|
|
|
|||
54
tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
Normal file
54
tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
Normal file
|
|
@ -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<NotSupportedException>(() =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
3
tests/vectors/document-variants-c0/.gitattributes
vendored
Normal file
3
tests/vectors/document-variants-c0/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Refusal vectors are byte-exact; never convert line endings.
|
||||
* binary
|
||||
*.py text
|
||||
Binary file not shown.
41
tests/vectors/document-variants-c0/generate.py
Normal file
41
tests/vectors/document-variants-c0/generate.py
Normal file
|
|
@ -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", []]]]])
|
||||
|
|
@ -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<30>‘•ªitem:anvilÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4032226+00:00Ä’¥anvil’¬item:bellows<77>
|
||||
BIN
tests/vectors/document-variants-c0/unknown-header.msgpack
Normal file
BIN
tests/vectors/document-variants-c0/unknown-header.msgpack
Normal file
Binary file not shown.
1
tests/vectors/document-variants-c0/v1-base.msgpack
Normal file
1
tests/vectors/document-variants-c0/v1-base.msgpack
Normal file
|
|
@ -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<65>’”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta
|
||||
BIN
tests/vectors/document-variants-c0/variant-v2.msgpack
Normal file
BIN
tests/vectors/document-variants-c0/variant-v2.msgpack
Normal file
Binary file not shown.
Loading…
Reference in a new issue