diff --git a/contracts/cultmesh/content-vectors.ts-written.json b/contracts/cultmesh/content-vectors.ts-written.json new file mode 100644 index 00000000..b262989a --- /dev/null +++ b/contracts/cultmesh/content-vectors.ts-written.json @@ -0,0 +1,99 @@ +{ + "vectors": [ + { + "label": "ts multi-chunk body", + "kind": "manifest", + "input": { + "artifactId": "Pixel Heroes/Hüt ☃", + "kind": "asset", + "version": "", + "mimeType": "application/x-cultnet-ts-vector", + "createdAtUtc": "2026-10-19T00:00:00.0000000+00:00", + "chunkSizeBytes": 131072, + "tags": [ + "a", + "b" + ], + "metadata": [ + [ + "10", + "x" + ], + [ + "alpha", + "y" + ], + [ + "2", + "z" + ] + ], + "body": { + "length": 777777, + "multiplier": 7, + "modulus": 256 + } + }, + "recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:unversioned:cf8a453a36981321977355448d24df49bcefee22be6e3730b9140e87c29013f9", + "messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBjZjhhNDUzYTM2OTgxMzIxOTc3MzU1NDQ4ZDI0ZGY0OWJjZWZlZTIyYmU2ZTM3MzBiOTE0MGU4N2MyOTAxM2Y5zgAL3jG/YXBwbGljYXRpb24veC1jdWx0bmV0LXRzLXZlY3RvctkhMjAyNi0xMC0xOVQwMDowMDowMC4wMDAwMDAwKzAwOjAwlpTZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDgAzgACAADZT21lc2g6Y2RuOmNodW5rOjM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDiU2UAzNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4zgACAADOAAIAANlPbWVzaDpjZG46Y2h1bms6Mzc3YzdiMGNjMjI5ZjQ3MDU2NzBkNjNkNTQ0YzI3NWI2NjNkZjZlOGM4MzFiMDk4NDZhNWM0OGNjYTA4Yjg0OJTZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAQAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4lNlAMzc3YzdiMGNjMjI5ZjQ3MDU2NzBkNjNkNTQ0YzI3NWI2NjNkZjZlOGM4MzFiMDk4NDZhNWM0OGNjYTA4Yjg0OM4ABgAAzgACAADZT21lc2g6Y2RuOmNodW5rOjM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDiU2UAzNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4zgAIAADOAAIAANlPbWVzaDpjZG46Y2h1bms6Mzc3YzdiMGNjMjI5ZjQ3MDU2NzBkNjNkNTQ0YzI3NWI2NjNkZjZlOGM4MzFiMDk4NDZhNWM0OGNjYTA4Yjg0OJTZQGEwYmI2OWY0NDg2MWViM2RiNTYwMjZhMWFiYmY5MWZmYTYyMTMyNTA4ZDVhMDZlMGQwZGRjNWNjMjZiZTMzZmPOAAoAAM4AAd4x2U9tZXNoOmNkbjpjaHVuazphMGJiNjlmNDQ4NjFlYjNkYjU2MDI2YTFhYmJmOTFmZmE2MjEzMjUwOGQ1YTA2ZTBkMGRkYzVjYzI2YmUzM2ZjkqFhoWKDojEwoXilYWxwaGGheaEyoXo=" + }, + { + "label": "ts empty body", + "kind": "manifest", + "input": { + "artifactId": "Pixel Heroes/Hüt ☃", + "kind": "asset", + "version": "", + "mimeType": "application/x-cultnet-ts-vector", + "createdAtUtc": "2026-10-19T00:00:00.0000000+00:00", + "chunkSizeBytes": 131072, + "tags": [ + "a", + "b" + ], + "metadata": [ + [ + "10", + "x" + ], + [ + "alpha", + "y" + ], + [ + "2", + "z" + ] + ], + "body": { + "length": 0, + "multiplier": 7, + "modulus": 256 + } + }, + "recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AL9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAA2U9tZXNoOmNkbjpjaHVuazplM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1kqFhoWKDojEwoXilYWxwaGGheaEyoXo=" + }, + { + "label": "ts single chunk, versioned", + "kind": "manifest", + "input": { + "artifactId": "Pixel Heroes/Hüt ☃", + "kind": "asset", + "version": "V1 Beta", + "mimeType": "application/x-cultnet-ts-vector", + "createdAtUtc": "2026-10-19T00:00:00.0000000+00:00", + "chunkSizeBytes": 4194304, + "tags": [], + "metadata": [], + "body": { + "length": 1000, + "multiplier": 7, + "modulus": 256 + } + }, + "recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:v1-beta:89f4ff56a25dd1db06a4ce6033603775d705fb96f30f8693733fef602a1ca532", + "messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSnVjEgQmV0YdlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMs0D6L9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRlNlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMgDNA+jZT21lc2g6Y2RuOmNodW5rOjg5ZjRmZjU2YTI1ZGQxZGIwNmE0Y2U2MDMzNjAzNzc1ZDcwNWZiOTZmMzBmODY5MzczM2ZlZjYwMmExY2E1MzKQgA==" + } + ] +} diff --git a/packages/cultcache-ts/src/document.ts b/packages/cultcache-ts/src/document.ts index 536e7709..0b019011 100644 --- a/packages/cultcache-ts/src/document.ts +++ b/packages/cultcache-ts/src/document.ts @@ -105,7 +105,7 @@ export function documentPayloadBytes(payload: DocumentPayload): Uint8Array { return payload as unknown as Uint8Array; } -export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth"; +export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH, checkPayloadDepth } from "./payload-depth"; // The payload nesting bound covers the MessagePack the library itself writes: the plain default formatter and a codec. A // user formatter's bytes are opaque and in any format; its format and bounds are its own. diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index 9147d4ea..cb0958a1 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -29,6 +29,11 @@ "types": "./dist/cultmesh-authority.d.ts", "require": "./dist/cultmesh-authority.js", "default": "./dist/cultmesh-authority.js" + }, + "./content": { + "types": "./dist/content.d.ts", + "require": "./dist/content.js", + "default": "./dist/content.js" } }, "files": [ diff --git a/packages/cultnet-ts/src/content.ts b/packages/cultnet-ts/src/content.ts new file mode 100644 index 00000000..d9316ab6 --- /dev/null +++ b/packages/cultnet-ts/src/content.ts @@ -0,0 +1,212 @@ +// The one TypeScript owner of the CultMesh CDN shapes: the manifest and chunk reference, their MessagePack form, hash +// normalization, both record-key grammars, SHA-256 hashing and packing. The C# reference is +// `src/GameCult.Mesh/CultMeshCdn.cs`; `contracts/cultmesh/content-vectors.*.json` and `cdn-record-key-vectors.json` pin +// the bytes and the keys. +// +// Browser-safe by construction: `crypto.subtle`, `TextEncoder`, `Uint8Array` and the codecs this package already ships. +// No `node:` import. A manifest names its chunks by hash and carries no address: where bytes are fetched from is the +// caller's to resolve through CultMesh. + +import { Decoder, encode } from "@msgpack/msgpack"; +import { checkPayloadDepth } from "@gamecult/cultcache-ts/document"; +import { isNullOrWhiteSpaceCSharp, trimCSharp } from "./cultmesh-authority"; + +export const CULTMESH_CDN_MANIFEST_SCHEMA = "gamecult.mesh.cdn_artifact_manifest.v1"; +export const DEFAULT_CHUNK_SIZE_BYTES = 4 * 1024 * 1024; +export const EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + +export interface CultMeshCdnChunkRef { + readonly chunkHash: string; + readonly offset: number; + readonly sizeBytes: number; + readonly recordKey: string; +} + +// Metadata is ordered entries, never an object: a JS object reorders integer-like keys, and the entry order is wire bytes. +export interface CultMeshCdnArtifactManifest { + readonly artifactId: string; + readonly kind: string; + readonly version: string; + readonly contentHash: string; + readonly sizeBytes: number; + readonly mimeType: string; + readonly createdAtUtc: string; + readonly chunks: readonly CultMeshCdnChunkRef[]; + readonly tags: readonly string[]; + readonly metadata: readonly (readonly [string, string])[]; +} + +export interface CultMeshCdnPackOptions { + readonly kind?: string; + readonly version?: string; + readonly mimeType?: string; + readonly createdAtUtc?: string; + readonly chunkSizeBytes?: number; + readonly tags?: readonly string[]; + readonly metadata?: readonly (readonly [string, string])[]; +} + +export interface CultMeshCdnPackedChunk { + readonly chunkHash: string; + readonly payload: Uint8Array; +} + +// ---- hashes and keys ------------------------------------------------------------------------------------------------- + +// `ToLowerInvariant`: each character on its own, so no context rule applies (a final sigma stays `σ`), and a character +// whose lower case is not a single character (`İ`) stays as it is. +const lowerInvariant = (text: string): string => + Array.from(text, character => { const lower = character.toLowerCase(); return Array.from(lower).length === 1 ? lower : character; }).join(""); + +/** `CultMeshCdn.NormalizeHash`: a case-insensitive `sha256:` prefix is stripped before the trim, and nothing checks for hex. */ +export function normalizeHash(hash: string, parameterName = "hash"): string { + if (isNullOrWhiteSpaceCSharp(hash)) throw new Error(`Hash must be non-empty. (Parameter '${parameterName}')`); + return lowerInvariant(trimCSharp(hash.slice(0, 7).toLowerCase() === "sha256:" ? hash.slice(7) : hash)); +} + +/** The hash a file name or a fetch may rest on: `normalizeHash`, then exactly 64 lowercase hexadecimal digits. */ +export function parseSha256Hex(hash: string, parameterName = "hash"): string { + const hex = normalizeHash(hash, parameterName); + if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error(`Hash must be 64 hexadecimal digits. (Parameter '${parameterName}')`); + return hex; +} + +export async function sha256Hex(bytes: Uint8Array): Promise { + const subtle = globalThis.crypto?.subtle; + if (!subtle) throw new Error("WebCrypto SHA-256 is unavailable."); + return Array.from(new Uint8Array(await subtle.digest("SHA-256", bytes as BufferSource)), byte => byte.toString(16).padStart(2, "0")).join(""); +} + +export const chunkRecordKey = (chunkHash: string): string => `mesh:cdn:chunk:${normalizeHash(chunkHash, "chunkHash")}`; + +// `CultMeshCdn.StableToken`, unit by unit: a letter or decimal digit, `-`, `_` and `.` survive, anything else (a lone +// surrogate included) becomes `-`, and a token that is empty after the trim falls back to the hash of the original. +async function stableToken(value: string, parameterName: string): Promise { + if (isNullOrWhiteSpaceCSharp(value)) throw new Error(`Value must be non-empty. (Parameter '${parameterName}')`); + const token = lowerInvariant(trimCSharp(value)).split("").map(unit => /^[\p{L}\p{Nd}\-_.]$/u.test(unit) ? unit : "-").join("").replace(/^-+|-+$/g, ""); + return token || sha256Hex(new TextEncoder().encode(value)); +} + +export async function artifactRecordKey(artifactId: string, version: string, contentHash: string): Promise { + const artifact = await stableToken(artifactId, "artifactId"); + const versioned = isNullOrWhiteSpaceCSharp(version) ? "unversioned" : await stableToken(version, "version"); + return `mesh:cdn:artifact:${artifact}:${versioned}:${normalizeHash(contentHash, "contentHash")}`; +} + +// ---- validation ------------------------------------------------------------------------------------------------------ + +/** `ValidateManifestShape`, in its order and with its messages, then the metadata keys a dictionary could not hold twice. */ +export function validateManifest(manifest: CultMeshCdnArtifactManifest): void { + if (manifest.sizeBytes < 0) throw new Error("CDN artifact manifest has a negative size."); + let expectedOffset = 0; + for (const reference of [...manifest.chunks].sort((a, b) => a.offset - b.offset)) { + if (reference.offset !== expectedOffset) throw new Error("CDN artifact chunks are not contiguous."); + if (reference.sizeBytes < 0) throw new Error("CDN artifact chunk has a negative size."); + expectedOffset += reference.sizeBytes; + } + if (expectedOffset !== manifest.sizeBytes) throw new Error("CDN artifact chunk sizes do not sum to the manifest size."); + if (new Set(manifest.metadata.map(([key]) => key)).size !== manifest.metadata.length) { + throw new Error("CDN artifact manifest metadata has a duplicate key."); + } +} + +// ---- MessagePack ----------------------------------------------------------------------------------------------------- + +const concat = (parts: readonly Uint8Array[]): Uint8Array => { + const bytes = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + parts.reduce((offset, part) => (bytes.set(part, offset), offset + part.length), 0); + return bytes; +}; + +const mapHeader = (count: number): Uint8Array => + count < 16 ? Uint8Array.of(0x80 | count) + : count < 0x10000 ? Uint8Array.of(0xde, count >> 8, count & 0xff) + : Uint8Array.of(0xdf, count >>> 24, (count >> 16) & 0xff, (count >> 8) & 0xff, count & 0xff); + +/** The positional ten-array of the C# `[MessagePackObject]`: chunk references are four-arrays, metadata a map in entry order. */ +export function encodeManifest(manifest: CultMeshCdnArtifactManifest): Uint8Array { + const { artifactId, kind, version, contentHash, sizeBytes, mimeType, createdAtUtc, chunks, tags, metadata } = manifest; + return concat([ + Uint8Array.of(0x9a), + ...[artifactId, kind, version, contentHash, sizeBytes, mimeType, createdAtUtc].map(field => encode(field)), + encode(chunks.map(c => [c.chunkHash, c.offset, c.sizeBytes, c.recordKey])), + encode(tags), + mapHeader(metadata.length), + ...metadata.flatMap(([key, value]) => [encode(key), encode(value)]), + ]); +} + +const wrongType = (field: string): never => { throw new Error(`CDN artifact manifest field '${field}' has the wrong type.`); }; +const text = (value: unknown, field: string): string => typeof value === "string" ? value : wrongType(field); +const list = (value: unknown, field: string): unknown[] => Array.isArray(value) ? value : wrongType(field); +const record = (value: unknown, field: string): Record => + typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Uint8Array) ? value as Record : wrongType(field); +const SAFE = BigInt(Number.MAX_SAFE_INTEGER); +// Any integer width, as the reference reads; a value a JS number cannot hold exactly is refused, never rounded. +const integer = (value: unknown, field: string): number => { + const number = typeof value === "bigint" ? (value >= -SAFE && value <= SAFE ? Number(value) : NaN) : value; + return typeof number === "number" && Number.isSafeInteger(number) ? number : wrongType(field); +}; + +/** The reference's reader: depth first, exact types, then `validateManifest`. */ +export function decodeManifest(bytes: Uint8Array): CultMeshCdnArtifactManifest { + checkPayloadDepth(bytes, "CDN artifact manifest"); + // The decoder builds a JS object, which reorders integer-like keys; the converter sees the keys in wire order. + const keys: string[] = []; + const decoder = new Decoder({ useBigInt64: true, mapKeyConverter: key => { keys.push(text(key, "metadata")); return key as string; } }); + const fields = list(decoder.decode(bytes), "manifest"); + if (fields.length !== 10) wrongType("manifest"); + const map = record(fields[9], "metadata"); + const manifest: CultMeshCdnArtifactManifest = { + artifactId: text(fields[0], "artifactId"), + kind: text(fields[1], "kind"), + version: text(fields[2], "version"), + contentHash: text(fields[3], "contentHash"), + sizeBytes: integer(fields[4], "sizeBytes"), + mimeType: text(fields[5], "mimeType"), + createdAtUtc: text(fields[6], "createdAtUtc"), + chunks: list(fields[7], "chunks").map(item => { + const chunk = list(item, "chunks"); + if (chunk.length !== 4) wrongType("chunks"); + const sizeBytes = integer(chunk[2], "chunks.sizeBytes"); + return { chunkHash: text(chunk[0], "chunks.chunkHash"), offset: integer(chunk[1], "chunks.offset"), + sizeBytes: sizeBytes >= -(2 ** 31) && sizeBytes < 2 ** 31 ? sizeBytes : wrongType("chunks.sizeBytes"), recordKey: text(chunk[3], "chunks.recordKey") }; + }), + tags: list(fields[8], "tags").map(tag => text(tag, "tags")), + metadata: keys.map(key => [key, text(map[key], "metadata")] as const), + }; + validateManifest(manifest); + return manifest; +} + +// ---- packing --------------------------------------------------------------------------------------------------------- + +/** `CultMeshCdn.PackArtifact`: content-addressed chunks and the manifest that names them. An empty body is one empty chunk. */ +export async function packContent( + artifactId: string, + bytes: Uint8Array, + options: CultMeshCdnPackOptions = {}, +): Promise<{ manifest: CultMeshCdnArtifactManifest; chunks: CultMeshCdnPackedChunk[] }> { + if (isNullOrWhiteSpaceCSharp(artifactId)) throw new Error("Artifact id must be non-empty. (Parameter 'artifactId')"); + const size = options.chunkSizeBytes ?? DEFAULT_CHUNK_SIZE_BYTES; + if (!Number.isInteger(size) || size < 1 || size > 2 ** 31 - 1) throw new Error("ChunkSizeBytes must be between 1 and 2147483647."); + const offsets = bytes.length === 0 ? [0] : Array.from({ length: Math.ceil(bytes.length / size) }, (_, index) => index * size); + const chunks = await Promise.all(offsets.map(async offset => { + const payload = bytes.slice(offset, offset + size); + return { chunkHash: await sha256Hex(payload), payload }; + })); + const manifest: CultMeshCdnArtifactManifest = { + artifactId, + kind: isNullOrWhiteSpaceCSharp(options.kind) ? "asset" : options.kind!, + version: options.version ?? "", + contentHash: await sha256Hex(bytes), + sizeBytes: bytes.length, + mimeType: isNullOrWhiteSpaceCSharp(options.mimeType) ? "application/octet-stream" : options.mimeType!, + createdAtUtc: options.createdAtUtc ?? `${new Date().toISOString().slice(0, 23)}0000+00:00`, + chunks: chunks.map(({ chunkHash, payload }, index) => ({ chunkHash, offset: offsets[index]!, sizeBytes: payload.length, recordKey: chunkRecordKey(chunkHash) })), + tags: options.tags ?? [], + metadata: options.metadata ?? [], + }; + validateManifest(manifest); + return { manifest, chunks }; +} diff --git a/packages/cultnet-ts/test/content.test.ts b/packages/cultnet-ts/test/content.test.ts new file mode 100644 index 00000000..214ee74a --- /dev/null +++ b/packages/cultnet-ts/test/content.test.ts @@ -0,0 +1,290 @@ +// The shared CultMesh CDN shapes, pinned rule by rule. +// +// Wire bytes and record keys are judged against files the C# reference and Rust write +// (`contracts/cultmesh/content-vectors.cs-written.json`, `.rs-written.json`, `cdn-record-key-vectors.json`); a fixture +// authored on this side alone would only agree with itself. The TypeScript-written file is judged here from its own +// declared inputs and by the C# reference in `CultMeshContentVectorTests`. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { build } from "esbuild"; +import { encode } from "@msgpack/msgpack"; +import { CultCachePayloadDepthError } from "@gamecult/cultcache-ts/document"; + +import { + EMPTY_SHA256, + artifactRecordKey, + chunkRecordKey, + decodeManifest, + encodeManifest, + normalizeHash, + packContent, + parseSha256Hex, + sha256Hex, + validateManifest, + type CultMeshCdnArtifactManifest, +} from "../src/content"; + +const CONTRACTS = join(__dirname, "..", "..", "..", "..", "contracts", "cultmesh"); +const vectors = (name: string): { vectors: { label: string; kind: string; messagePackBase64: string }[] } => + JSON.parse(readFileSync(join(CONTRACTS, name), "utf8")); +const manifestVectors = (name: string) => vectors(name).vectors.filter(v => v.kind === "manifest") + .map(v => ({ label: v.label, bytes: new Uint8Array(Buffer.from(v.messagePackBase64, "base64")) })); +const csManifest = (label: string) => manifestVectors("content-vectors.cs-written.json").find(v => v.label === label)!.bytes; + +// The C# fixture: byte i is i % 251, 262,144-byte chunks, and the options its test passes. +const FIXTURE_BODY = Uint8Array.from({ length: 1_315_551 }, (_, i) => i % 251); +const FIXTURE_OPTIONS = { + chunkSizeBytes: 262_144, + version: "1", + mimeType: "application/x-cultnet-vector", + createdAtUtc: "2026-09-22T00:00:00.0000000+00:00", +}; +const FIXTURE_HASH = "f4ae2a7c95f4d966058f75c13185aa0ab6a91fc5afad886296ef2aee755a2af1"; +const HASH = "a".repeat(64); + +const manifestOf = (over: Partial = {}): CultMeshCdnArtifactManifest => ({ + artifactId: "id", kind: "asset", version: "1", contentHash: HASH, sizeBytes: 4, mimeType: "text/plain", createdAtUtc: "t", + chunks: [{ chunkHash: HASH, offset: 0, sizeBytes: 4, recordKey: `mesh:cdn:chunk:${HASH}` }], tags: ["t"], metadata: [["k", "v"]], + ...over, +}); +// Wire bytes of a ten-array with any field replaced, metadata as a plain object (no integer-like keys). +const wire = (over: Record = {}) => { + const m = manifestOf(); + const fields: unknown[] = [m.artifactId, m.kind, m.version, m.contentHash, m.sizeBytes, m.mimeType, m.createdAtUtc, + m.chunks.map(c => [c.chunkHash, c.offset, c.sizeBytes, c.recordKey]), m.tags, { k: "v" }]; + for (const [index, value] of Object.entries(over)) fields[Number(index)] = value; + return encode(fields, { useBigInt64: true, maxDepth: 1000 }); +}; +const refusal = (bytes: Uint8Array) => assert.throws(() => decodeManifest(bytes), /./); + +test("each C# manifest vector decodes and re-encodes byte-identically", () => { + const vs = manifestVectors("content-vectors.cs-written.json"); + assert.deepEqual(vs.map(v => v.label), ["manifest", "manifest_out_of_order", "manifest_metadata_order", "manifest_empty_body"]); + for (const { label, bytes } of vs) assert.deepEqual(encodeManifest(decodeManifest(bytes)), bytes, label); +}); + +test("the Rust-written manifests decode and re-encode byte-identically", () => { + const vs = manifestVectors("content-vectors.rs-written.json"); + assert.ok(vs.length >= 4); + for (const { label, bytes } of vs) assert.deepEqual(encodeManifest(decodeManifest(bytes)), bytes, label); +}); + +test("packContent of the C# fixture body and options equals the cs-written manifest, and of an empty body manifest_empty_body", async () => { + const { manifest, chunks } = await packContent("eureka/content-vector", FIXTURE_BODY, FIXTURE_OPTIONS); + assert.deepEqual(encodeManifest(manifest), csManifest("manifest")); + assert.equal(manifest.contentHash, FIXTURE_HASH); + assert.equal(chunks.length, 6); + const empty = await packContent("eureka/content-vector", new Uint8Array(0), FIXTURE_OPTIONS); + assert.deepEqual(encodeManifest(empty.manifest), csManifest("manifest_empty_body")); + assert.equal(empty.chunks.length, 1, "an empty artifact packs one chunk"); + assert.equal(empty.chunks[0]!.payload.length, 0); + assert.equal(empty.chunks[0]!.chunkHash, EMPTY_SHA256); +}); + +test("the packed chunks are copies that rejoin to the body, each named by the hash of its bytes", async () => { + const body = Uint8Array.from({ length: 10 }, (_, i) => i); + const { manifest, chunks } = await packContent("x", body, { chunkSizeBytes: 4 }); + body.fill(9); + assert.deepEqual(chunks.map(c => c.payload.length), [4, 4, 2]); + assert.deepEqual(Array.from(chunks.flatMap(c => Array.from(c.payload))), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + for (const [i, c] of chunks.entries()) { + assert.equal(c.chunkHash, await sha256Hex(c.payload)); + assert.deepEqual(manifest.chunks[i], { chunkHash: c.chunkHash, offset: i * 4, sizeBytes: c.payload.length, recordKey: chunkRecordKey(c.chunkHash) }); + } + const twins = await packContent("x", new Uint8Array(8), { chunkSizeBytes: 4 }); + assert.equal(twins.manifest.chunks[0]!.chunkHash, twins.manifest.chunks[1]!.chunkHash, "equal chunks share a hash"); +}); + +test("pack defaults: kind asset, mime octet-stream, empty version, tags and metadata", async () => { + const bare = (await packContent("x", new Uint8Array(3))).manifest; + assert.deepEqual([bare.kind, bare.mimeType, bare.version, bare.tags, bare.metadata], ["asset", "application/octet-stream", "", [], []]); + const blank = (await packContent("x", new Uint8Array(3), { kind: " ", mimeType: "" })).manifest; + assert.deepEqual([blank.kind, blank.mimeType], ["asset", "application/octet-stream"]); + const given = (await packContent("x", new Uint8Array(3), { kind: "build", mimeType: "text/plain", version: "2", tags: ["a"], metadata: [["k", "v"]] })).manifest; + assert.deepEqual([given.kind, given.mimeType, given.version, given.tags, given.metadata], ["build", "text/plain", "2", ["a"], [["k", "v"]]]); + assert.equal(given.artifactId, "x"); +}); + +test("packContent refuses a blank artifact id, a chunk size outside 1..2147483647 and duplicate metadata keys", async () => { + for (const id of ["", " ", "\u0085\u00a0"]) await assert.rejects(packContent(id, new Uint8Array(1)), /Artifact id must be non-empty/); + for (const chunkSizeBytes of [0, -1, 2 ** 31, 1.5, NaN]) await assert.rejects(packContent("x", new Uint8Array(1), { chunkSizeBytes }), /ChunkSizeBytes/); + await packContent("x", new Uint8Array(1), { chunkSizeBytes: 2 ** 31 - 1 }); + await assert.rejects(packContent("x", new Uint8Array(1), { metadata: [["a", "1"], ["a", "2"]] }), /duplicate key/); +}); + +test("default createdAtUtc matches the C# round-trip 'O' format", async () => { + const { manifest } = await packContent("x", new Uint8Array(1)); + assert.match(manifest.createdAtUtc, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{7}\+00:00$/); + assert.ok(Math.abs(Date.parse(manifest.createdAtUtc.replace(/(\.\d{3})\d{4}\+00:00$/, "$1Z")) - Date.now()) < 60_000); +}); + +test("manifest_out_of_order decodes and validates, and its chunks keep wire order", () => { + const decoded = decodeManifest(csManifest("manifest_out_of_order")); + assert.deepEqual(decoded.chunks.map(c => c.offset / 262_144), [3, 0, 5, 1, 4, 2]); +}); + +test("metadata is ordered entries, immune to JS integer-key ordering", () => { + assert.deepEqual(decodeManifest(csManifest("manifest_metadata_order")).metadata, [["zeta", "1"], ["alpha", "2"], ["mid", "3"]]); + const metadata = [["10", "a"], ["alpha", "b"], ["2", "c"]] as const; + const bytes = encodeManifest(manifestOf({ metadata })); + assert.deepEqual(decodeManifest(bytes).metadata, metadata); + assert.deepEqual(encodeManifest(decodeManifest(bytes)), bytes); +}); + +test("a map larger than a fixmap round-trips in entry order", () => { + for (const count of [16, 70_000]) { + const metadata = Array.from({ length: count }, (_, i) => [`k${count - i}`, "v"] as const); + const bytes = encodeManifest(manifestOf({ metadata })); + assert.deepEqual(decodeManifest(bytes).metadata, metadata); + } +}); + +test("a manifest with a duplicated metadata key is refused, in decode and in validate", () => { + const metadata = [["a", "1"], ["b", "x"], ["a", "2"]] as const; + assert.throws(() => decodeManifest(encodeManifest(manifestOf({ metadata }))), /duplicate key/); + assert.throws(() => validateManifest(manifestOf({ metadata })), /duplicate key/); + validateManifest(manifestOf({ metadata: [["a", "1"], ["b", "1"]] })); +}); + +test("a metadata key that is not a string, and a __proto__ key, are refused", () => { + const withMap = (...entry: Uint8Array[]) => Uint8Array.from([...wire().slice(0, -5), 0x81, ...entry.flatMap(part => Array.from(part))]); + decodeManifest(withMap(encode("k"), encode("v"))); + refusal(withMap(encode(5), encode("v"))); + refusal(withMap(encode(new Uint8Array(2)), encode("v"))); + refusal(withMap(encode("__proto__"), encode("v"))); +}); + +test("an integer field accepts any width and refuses what a JS number cannot hold exactly", () => { + const empty = { 4: 0, 7: [] as unknown[] }; + // Past the integer rule, a size nothing sums to fails the sum check; a refused integer fails the type check first. + assert.throws(() => decodeManifest(wire({ ...empty, 4: 2 ** 53 - 1 })), /do not sum/); + assert.throws(() => decodeManifest(wire({ ...empty, 4: 2n ** 53n - 1n })), /do not sum/); + assert.throws(() => decodeManifest(wire({ ...empty, 4: 2n ** 53n + 1n })), /wrong type/); + assert.throws(() => decodeManifest(wire({ ...empty, 4: -(2n ** 53n) })), /wrong type/); + assert.throws(() => decodeManifest(wire({ ...empty, 4: 0.5 })), /wrong type/); + // A 64-bit encoding of a small value is the same value. + const marked = wire({ 4: 0xdeadbeef, 7: [[HASH, 0, 4, "k"]] }); + const at = marked.findIndex((_, i) => marked[i] === 0xce && marked[i + 1] === 0xde && marked[i + 4] === 0xef); + const wide = Uint8Array.from([...marked.slice(0, at), 0xcf, 0, 0, 0, 0, 0, 0, 0, 4, ...marked.slice(at + 5)]); + assert.equal(decodeManifest(wide).sizeBytes, 4); +}); + +test("a chunk size outside int32 is refused as the reference's int cannot hold it", () => { + const chunk = (size: number) => decodeManifest(wire({ 4: size, 7: [[HASH, 0, size, "k"]] })); + assert.equal(chunk(2 ** 31 - 1).chunks[0]!.sizeBytes, 2 ** 31 - 1); + assert.throws(() => chunk(2 ** 31), /wrong type/); + assert.throws(() => decodeManifest(wire({ 4: -(2 ** 31) - 1, 7: [[HASH, 0, -(2 ** 31) - 1, "k"]] })), /wrong type/); +}); + +test("a payload nested 257 deep is refused before decoding and 256 is not refused for depth", () => { + const nest = (depth: number) => Uint8Array.from([...Array(depth).fill(0x91), 0xc0]); + assert.throws(() => decodeManifest(nest(257)), CultCachePayloadDepthError); + assert.throws(() => decodeManifest(nest(256)), (error: unknown) => error instanceof Error && !(error instanceof CultCachePayloadDepthError)); + // The nesting is inside a field of an otherwise valid manifest: the outer array is depth 1. + const inside = (depth: number) => wire({ 8: Array.from({ length: depth }).reduce(value => [value], null) }); + assert.throws(() => decodeManifest(inside(256)), CultCachePayloadDepthError); + assert.throws(() => decodeManifest(inside(255)), (error: unknown) => error instanceof Error && !(error instanceof CultCachePayloadDepthError)); +}); + +test("a manifest of the wrong shape is refused naming the field and never the value", () => { + const canary = "CANARY-7f3a91"; + for (const bytes of [ + wire({ 0: 5 }), wire({ 1: 5 }), wire({ 2: 5 }), wire({ 3: 5 }), wire({ 5: 5 }), wire({ 6: 5 }), + wire({ 4: canary }), wire({ 7: canary }), wire({ 7: [canary] }), wire({ 7: [[HASH, 0, 4]] }), wire({ 7: [[HASH, canary, 4, "k"]] }), + wire({ 7: [[5, 0, 4, "k"]] }), wire({ 7: [[HASH, 0, 4, 5]] }), wire({ 8: canary }), wire({ 8: [5] }), wire({ 9: canary }), wire({ 9: { k: 5 } }), + wire({ 9: null }), wire({ 9: [] }), wire({ 0: null }), encode([canary]), encode(canary), encode(canary).slice(0, 4), + Uint8Array.from([...wire(), 0xc0]), encode([...Array(11).fill(canary)]), wire({ 7: [[HASH, 0, 4, "k", canary]] }), + ]) { + assert.throws(() => decodeManifest(bytes), (error: unknown) => error instanceof Error && !error.message.includes(canary) && error.message.length > 0); + } +}); + +test("validateManifest refuses negative size, a gap, an overlap and a size-sum mismatch with the C# messages", () => { + const chunk = (offset: number, sizeBytes: number) => ({ chunkHash: HASH, offset, sizeBytes, recordKey: "k" }); + const refuses = (over: Partial, message: string) => + assert.throws(() => validateManifest(manifestOf(over)), new Error(message)); + refuses({ sizeBytes: -1, chunks: [] }, "CDN artifact manifest has a negative size."); + refuses({ sizeBytes: 4, chunks: [chunk(0, 2), chunk(3, 1)] }, "CDN artifact chunks are not contiguous."); + refuses({ sizeBytes: 4, chunks: [chunk(0, 3), chunk(2, 2)] }, "CDN artifact chunks are not contiguous."); + refuses({ sizeBytes: 4, chunks: [chunk(1, 4)] }, "CDN artifact chunks are not contiguous."); + refuses({ sizeBytes: 0, chunks: [chunk(0, -1)] }, "CDN artifact chunk has a negative size."); + refuses({ sizeBytes: 5, chunks: [chunk(0, 4)] }, "CDN artifact chunk sizes do not sum to the manifest size."); + refuses({ sizeBytes: 3, chunks: [chunk(0, 4)] }, "CDN artifact chunk sizes do not sum to the manifest size."); + refuses({ sizeBytes: -1, chunks: [chunk(1, 1)] }, "CDN artifact manifest has a negative size."); + validateManifest(manifestOf({ sizeBytes: 4, chunks: [chunk(2, 2), chunk(0, 2)] })); + validateManifest(manifestOf({ sizeBytes: 0, chunks: [] })); +}); + +test("normalizeHash: 'SHA256:ABC ' -> 'abc', ' sha256:abc' -> 'sha256:abc', blank throws", () => { + assert.equal(normalizeHash("SHA256:ABC "), "abc"); + assert.equal(normalizeHash("sHa256:AbC"), "abc"); + assert.equal(normalizeHash(" sha256:abc"), "sha256:abc"); + assert.equal(normalizeHash("sha256:sha256:ab"), "sha256:ab"); + assert.equal(normalizeHash("sha256: "), ""); + assert.equal(normalizeHash("\u00a0\u0130X\u00a0"), "\u0130x"); + for (const blank of ["", " ", "\u0085\u2003"]) assert.throws(() => normalizeHash(blank, "chunkHash"), /Hash must be non-empty\. \(Parameter 'chunkHash'\)/); +}); + +test("parseSha256Hex accepts 64 hex digits in any case and prefix, and refuses anything else without echoing it", () => { + assert.equal(parseSha256Hex(`SHA256: ${"AB".repeat(32)} `), "ab".repeat(32)); + for (const bad of ["a".repeat(63), "a".repeat(65), `${"a".repeat(63)}g`, "", "sha256:", `sha256:CANARY-${"a".repeat(50)}`]) { + assert.throws(() => parseSha256Hex(bad), (error: unknown) => error instanceof Error && !error.message.includes("CANARY")); + } +}); + +test("every entry in cdn-record-key-vectors.json derives to its recordKey", async () => { + const file = JSON.parse(readFileSync(join(CONTRACTS, "cdn-record-key-vectors.json"), "utf8")) as { + artifacts: { artifactIdUnits: number[]; versionUnits: number[]; contentHash: string; recordKey: string }[]; + chunks: { chunkHash: string; recordKey: string }[]; + }; + assert.ok(file.artifacts.length >= 14 && file.chunks.length >= 7, "the file carries the C# traps"); + const text = (units: number[]) => String.fromCharCode(...units); + for (const a of file.artifacts) { + assert.equal(await artifactRecordKey(text(a.artifactIdUnits), text(a.versionUnits), a.contentHash), a.recordKey, JSON.stringify(a.artifactIdUnits)); + } + for (const c of file.chunks) assert.equal(chunkRecordKey(c.chunkHash), c.recordKey, c.chunkHash); + assert.ok(file.artifacts.some(a => a.artifactIdUnits.includes(0xd800)), "a lone surrogate survives the file"); +}); + +test("record keys refuse a blank id, version-less artifacts are 'unversioned', and a blank hash throws", async () => { + await assert.rejects(artifactRecordKey(" ", "1", HASH), /Value must be non-empty\. \(Parameter 'artifactId'\)/); + await assert.rejects(artifactRecordKey("a", "1", " "), /Hash must be non-empty\. \(Parameter 'contentHash'\)/); + assert.throws(() => chunkRecordKey(""), /\(Parameter 'chunkHash'\)/); + assert.equal(await artifactRecordKey("a", " ", HASH), `mesh:cdn:artifact:a:unversioned:${HASH}`); + assert.equal(chunkRecordKey(`SHA256:${HASH.toUpperCase()}`), `mesh:cdn:chunk:${HASH}`); +}); + +test("sha256Hex of '' and of the fixture body equal the published digests", async () => { + assert.equal(await sha256Hex(new Uint8Array(0)), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + assert.equal(await sha256Hex(FIXTURE_BODY), FIXTURE_HASH); + assert.equal(await sha256Hex(new TextEncoder().encode("abc")), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + assert.equal(EMPTY_SHA256, await sha256Hex(new Uint8Array(0))); +}); + +test("esbuild bundles dist/content.js with platform 'browser' without error", async () => { + const result = await build({ + entryPoints: [join(__dirname, "..", "..", "dist", "content.js")], bundle: true, platform: "browser", format: "esm", write: false, logLevel: "silent", + }); + assert.ok(result.outputFiles[0]!.text.includes("gamecult.mesh.cdn_artifact_manifest.v1")); +}); + +test("the committed ts-written file re-packs from its declared inputs to the same bytes and record keys", async () => { + type Input = { artifactId: string; kind: string; version: string; mimeType: string; createdAtUtc: string; chunkSizeBytes: number; tags: string[]; + metadata: [string, string][]; body: { length: number; multiplier: number; modulus: number } }; + const file = JSON.parse(readFileSync(join(CONTRACTS, "content-vectors.ts-written.json"), "utf8")) as + { vectors: { label: string; kind: string; input: Input; recordKey: string; messagePackBase64: string }[] }; + assert.ok(file.vectors.length >= 3); + assert.ok(file.vectors.some(v => v.input.body.length === 0) && file.vectors.some(v => v.input.body.length > v.input.chunkSizeBytes)); + for (const { label, kind, input, recordKey, messagePackBase64 } of file.vectors) { + assert.equal(kind, "manifest"); + const { body, ...options } = input; + const { manifest } = await packContent(input.artifactId, Uint8Array.from({ length: body.length }, (_, i) => (i * body.multiplier) % body.modulus), options); + const bytes = new Uint8Array(Buffer.from(messagePackBase64, "base64")); + assert.deepEqual(encodeManifest(manifest), bytes, label); + assert.deepEqual(encodeManifest(decodeManifest(bytes)), bytes, label); + assert.equal(await artifactRecordKey(manifest.artifactId, manifest.version, manifest.contentHash), recordKey, label); + } +}); diff --git a/scripts/write-cultmesh-content-vectors.mjs b/scripts/write-cultmesh-content-vectors.mjs new file mode 100644 index 00000000..baf62426 --- /dev/null +++ b/scripts/write-cultmesh-content-vectors.mjs @@ -0,0 +1,60 @@ +// The TypeScript -> C# direction of the CultMesh content parity check. +// +// `contracts/cultmesh/content-vectors.cs-written.json` and `.rs-written.json` are written by the reference and by Rust +// and decoded by TypeScript. This script writes the mirror file, `contracts/cultmesh/content-vectors.ts-written.json`: +// `packages/cultnet-ts/src/content.ts` packs each declared input and encodes the manifest, and the C# test +// `CultMeshContentVectorTests.TypeScriptManifestVectorsMatchTheReference` judges the committed file with the reference +// only (it decodes the bytes, packs the same input itself and compares, and derives the record key). +// +// Deliberately not the C# file's inputs: a multi-byte artifact id, no version, integer-like metadata keys that a JS object +// would reorder, a different body and chunk size. Agreeing on a copy of the other side's inputs would prove less. +// +// npm run build --workspace packages/cultnet-ts +// node scripts/write-cultmesh-content-vectors.mjs +// +// Nothing here is randomised: rerunning on an unchanged codec rewrites the same bytes. + +import { existsSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const modulePath = join(repoRoot, "packages", "cultnet-ts", "dist", "content.js"); +const outputPath = join(repoRoot, "contracts", "cultmesh", "content-vectors.ts-written.json"); +if (!existsSync(modulePath)) { + throw new Error(`${modulePath} is missing; run 'npm run build --workspace packages/cultnet-ts' first.`); +} +const { packContent, encodeManifest, artifactRecordKey } = createRequire(import.meta.url)(modulePath); + +const common = { + artifactId: "Pixel Heroes/H\u00fct \u2603", + kind: "asset", + version: "", + mimeType: "application/x-cultnet-ts-vector", + createdAtUtc: "2026-10-19T00:00:00.0000000+00:00", + chunkSizeBytes: 131072, + tags: ["a", "b"], + metadata: [["10", "x"], ["alpha", "y"], ["2", "z"]], +}; +const inputs = [ + ["ts multi-chunk body", { ...common, body: { length: 777777, multiplier: 7, modulus: 256 } }], + ["ts empty body", { ...common, body: { length: 0, multiplier: 7, modulus: 256 } }], + ["ts single chunk, versioned", { ...common, version: "V1 Beta", chunkSizeBytes: 4194304, tags: [], metadata: [], body: { length: 1000, multiplier: 7, modulus: 256 } }], +]; + +const vectors = []; +for (const [label, input] of inputs) { + const { body, ...options } = input; + const bytes = Uint8Array.from({ length: body.length }, (_, index) => (index * body.multiplier) % body.modulus); + const { manifest } = await packContent(input.artifactId, bytes, options); + vectors.push({ + label, + kind: "manifest", + input, + recordKey: await artifactRecordKey(manifest.artifactId, manifest.version, manifest.contentHash), + messagePackBase64: Buffer.from(encodeManifest(manifest)).toString("base64"), + }); +} +writeFileSync(outputPath, `${JSON.stringify({ vectors }, null, 2)}\n`); +console.log(`wrote ${vectors.length} vectors to ${outputPath}`);