From 11218b88892dad1ece5b3ef74d3579e3e34254e8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 20:45:35 +0200 Subject: [PATCH] C4 fix: parity fixtures carry a recordKey with every Uri.EscapeDataString special character A fourth fixture (escape) has a chunk recordKey of k!'()* %41+~. The provider's honest mount answers that one key as the canonical key after logging the raw URL, so both consumers fetch the bytes and the log holds what each actually sent. The orchestrator asserts the C# and TS URLs are identical and equal the RFC 3986 escaping. Deleting the reader's !'() * escape now fails test:interop. Co-Authored-By: Claude Opus 5.5 --- .../test/interop/content-https-provider.ts | 15 ++++++++++++--- .../test/interop/content-https-reader.ts | 1 + .../test/interop/content-https.test.ts | 18 +++++++++++++++--- .../CultMeshHttpsContentInteropTests.cs | 2 +- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/cultmesh-browser/test/interop/content-https-provider.ts b/packages/cultmesh-browser/test/interop/content-https-provider.ts index e03e34a7..50b5f223 100644 --- a/packages/cultmesh-browser/test/interop/content-https-provider.ts +++ b/packages/cultmesh-browser/test/interop/content-https-provider.ts @@ -1,13 +1,14 @@ -// The TypeScript content provider in its own process, for content-https.test.ts. Argv: . +// The TypeScript content provider in its own process, for content-https.test.ts. Argv: . // Protocol, stdout JSON lines only: ready, one line per request ({phase, mount, url}), one per server fault ({phase, mount, fault}). // Stdin: "phase " sets the phase and is answered {ack}; stdin ending closes the server and exits, so no orchestrator crash orphans it. import { readFileSync } from "node:fs"; import { createServer } from "node:https"; import type { AddressInfo } from "node:net"; import { createInterface } from "node:readline"; +import { chunkRecordKey } from "cultnet-ts/content"; import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener, type ContentChunkAnswer, type ContentChunkRequest } from "cultmesh-ts/content"; -const [storeDir, corruptDir, keyPem, certPem] = process.argv.slice(2) as [string, string, string, string]; +const [storeDir, corruptDir, keyPem, certPem, escapeKey] = process.argv.slice(2) as [string, string, string, string, string]; const say = (line: object): void => { process.stdout.write(`${JSON.stringify(line)}\n`); }; let phase = "start"; @@ -23,7 +24,15 @@ const tampered = async (request: ContentChunkRequest): Promise => { + const url = new URL(request.url, "http://provider.invalid"); + if (url.searchParams.get("recordKey") !== escapeKey) return honest(request); + url.searchParams.set("recordKey", chunkRecordKey(url.searchParams.get("chunkHash"))); + return honest({ ...request, url: `${url.pathname}${url.search}` }); +}; +const mounts = { honest: toNodeListener(honestEscape), tampered: toNodeListener(tampered), corrupt: toNodeListener(handlerFor("corrupt", corruptDir)) }; const server = createServer({ key: readFileSync(keyPem), cert: readFileSync(certPem) }, (req, res) => { const mount = /^\/(honest|tampered|corrupt)\//.exec(req.url ?? "")?.[1] as keyof typeof mounts | undefined; diff --git a/packages/cultmesh-browser/test/interop/content-https-reader.ts b/packages/cultmesh-browser/test/interop/content-https-reader.ts index 6b6ca52f..693b22a8 100644 --- a/packages/cultmesh-browser/test/interop/content-https-reader.ts +++ b/packages/cultmesh-browser/test/interop/content-https-reader.ts @@ -15,6 +15,7 @@ const cases: Record Promise> = { "honest-multi": () => fetchVerifiedContent(endpoints.honest, manifest("multi")), "honest-single": () => fetchVerifiedContent(endpoints.honest, single), "honest-empty": () => fetchVerifiedContent(endpoints.honest, manifest("empty")), + "honest-escape": () => fetchVerifiedContent(endpoints.honest, manifest("escape")), "honest-single-chunk": () => fetchVerifiedChunk(endpoints.honest, single.chunks[0]!.chunkHash!, single.chunks[0]!.sizeBytes), "tampered-multi": () => fetchVerifiedContent(endpoints.tampered, manifest("multi")), "corrupt-single": () => fetchVerifiedContent(endpoints.corrupt, single), diff --git a/packages/cultmesh-browser/test/interop/content-https.test.ts b/packages/cultmesh-browser/test/interop/content-https.test.ts index ef185dce..c86cf6ae 100644 --- a/packages/cultmesh-browser/test/interop/content-https.test.ts +++ b/packages/cultmesh-browser/test/interop/content-https.test.ts @@ -21,6 +21,10 @@ const repoRoot = resolve(packageRoot, "../.."); const fixtures = join(packageRoot, "test/fixtures"); const keyPem = join(fixtures, "content-https-test-key.pem"); const certPem = join(fixtures, "content-https-test-cert.pem"); +// A chunk recordKey with every character Uri.EscapeDataString treats specially: ! ' ( ) * (which encodeURIComponent leaves bare), a space, +// a non-ASCII character and a lone percent sign. RecordKey is a free string on a chunk ref, and the connector sends it verbatim into the query. +const escapeKey = "k!'()* é%41+~"; +const escapedKey = "k%21%27%28%29%2A%20%C3%A9%2541%2B~"; const dotnet = process.env.DOTNET ?? "dotnet"; const haveDotnet = (() => { try { execFileSync(dotnet, ["--version"], { stdio: "ignore" }); return true; } catch { return false; } })(); @@ -74,16 +78,19 @@ test("the TS provider serves both consumers: parity, refusal and the fault log", const multi = await packContent("c4/multi", xorshiftBytes(229_376, 0x9e3779b9), { chunkSizeBytes: 65_536, createdAtUtc }); const single = await packContent("c4/single", xorshiftBytes(10_000, 0x85ebca6b), { createdAtUtc }); const empty = await packContent("c4/empty", new Uint8Array(0), { createdAtUtc }); + const escaped = await packContent("c4/escape", xorshiftBytes(5_000, 0xc2b2ae35), { createdAtUtc }); + const escapeManifest = { ...escaped.manifest, chunks: escaped.manifest.chunks.map(chunk => ({ ...chunk, recordKey: escapeKey })) }; + const escapeHash = escaped.chunks[0]!.chunkHash; const multiHashes = multi.chunks.map(chunk => chunk.chunkHash); assert.equal(multi.chunks.length, 4); assert.equal(new Set(multiHashes).size, 4, "a parity fixture needs four distinct chunk hashes"); - for (const chunk of [...multi.chunks, ...single.chunks, ...empty.chunks]) writeFileSync(join(store, chunk.chunkHash), chunk.payload); + for (const chunk of [...multi.chunks, ...single.chunks, ...empty.chunks, ...escaped.chunks]) writeFileSync(join(store, chunk.chunkHash), chunk.payload); const flipped = single.chunks[0]!.payload.slice(); flipped[0]! ^= 1; writeFileSync(join(corrupt, single.chunks[0]!.chunkHash), flipped); - for (const [name, packed] of Object.entries({ multi, single, empty })) writeFileSync(join(manifests, `${name}.manifest`), encodeManifest(packed.manifest)); + for (const [name, manifest] of Object.entries({ multi: multi.manifest, single: single.manifest, empty: empty.manifest, escape: escapeManifest })) writeFileSync(join(manifests, `${name}.manifest`), encodeManifest(manifest)); - const provider = startProvider([store, corrupt, keyPem, certPem]); + const provider = startProvider([store, corrupt, keyPem, certPem, escapeKey]); try { const endpoints = (await provider.next(line => line.status === "ready", "ready")).endpoints!; const endpointsJson = JSON.stringify(endpoints); @@ -104,6 +111,7 @@ test("the TS provider serves both consumers: parity, refusal and the fault log", for (const [name, packed] of [["honest-multi", multi], ["honest-single", single], ["honest-empty", empty]] as const) { assert.deepEqual(read[name], { case: name, ok: true, sha256: packed.manifest.contentHash?.replace(/^sha256:/i, "") }, name); } + assert.deepEqual(read["honest-escape"], { case: "honest-escape", ok: true, sha256: escaped.manifest.contentHash?.replace(/^sha256:/i, "") }, "honest-escape"); assert.deepEqual(read["honest-single-chunk"], { case: "honest-single-chunk", ok: true, sha256: single.manifest.contentHash?.replace(/^sha256:/i, "") }); assert.equal(read["tampered-multi"]?.ok, false); assert.equal(read["tampered-multi"]?.code, "chunk-hash-mismatch"); @@ -130,6 +138,10 @@ test("the TS provider serves both consumers: parity, refusal and the fault log", const multiUrls = (phase: string) => new Set(requests(phase, "honest").map(line => line.url!).filter(url => multiHashes.some(hash => url.includes(`chunkHash=${hash}`)))); assert.equal(multiUrls("reader").size, 4); assert.deepEqual([...multiUrls("dotnet")].sort(), [...multiUrls("reader")].sort()); + // (a2) the escape chunk: the C# connector's URL and the TS reader's URL are the same bytes, and they are the RFC 3986 escaping of escapeKey + const escapeUrls = (phase: string) => requests(phase, "honest").map(line => line.url!).filter(url => url.includes(`chunkHash=${escapeHash}`)); + assert.deepEqual(escapeUrls("reader"), [`/honest/content?chunkHash=${escapeHash}&recordKey=${escapedKey}`]); + assert.deepEqual(escapeUrls("dotnet"), escapeUrls("reader")); // (b) F30: C# asks for the empty chunk; the reader verifies it locally assert.ok(requests("dotnet", "honest").some(line => line.url!.includes(`chunkHash=${EMPTY_SHA256}`))); assert.ok(!requests("reader", "honest").some(line => line.url!.includes(`chunkHash=${EMPTY_SHA256}`))); diff --git a/tests/GameCult.Mesh.Tests/CultMeshHttpsContentInteropTests.cs b/tests/GameCult.Mesh.Tests/CultMeshHttpsContentInteropTests.cs index 6f5850a1..be593e5e 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshHttpsContentInteropTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshHttpsContentInteropTests.cs @@ -101,7 +101,7 @@ public sealed class CultMeshHttpsContentInteropTests public async Task HttpsTransferFetchesFromExternalTypeScriptProvider() { using var rig = Build(_honest, _anchor, _dir); - foreach (var name in new[] { "multi", "single", "empty" }) + foreach (var name in new[] { "multi", "single", "empty", "escape" }) { var manifest = Manifest(name); var path = await rig.Transfer.FetchAsync(manifest);