From 424913bfb76b7b03adae7ea70673b07757265a9d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 19 Oct 2026 19:45:10 +0200 Subject: [PATCH] C4: cross-process HTTPS content parity (C# transfer service, TS provider, TS reader) The TS provider runs in its own process on node:https with the fixture credential. The TS reader and the C# transfer service over the unmodified HTTPS connector (CustomRootTrust, fixture certificate only) each fetch from it; the orchestrator judges URL parity, the empty chunk, corrupt-store faults and the untrusted control. test:interop is wired in cultmesh-browser and the workflow, and the cultmesh-ts step now precedes the browser step. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/cultmesh-portability.yml | 14 +- packages/cultmesh-browser/package.json | 4 +- .../test/interop/content-https-provider.ts | 42 +++++ .../test/interop/content-https-reader.ts | 31 ++++ .../test/interop/content-https.test.ts | 147 +++++++++++++++ .../CultMeshHttpsContentInteropTests.cs | 169 ++++++++++++++++++ 6 files changed, 403 insertions(+), 4 deletions(-) create mode 100644 packages/cultmesh-browser/test/interop/content-https-provider.ts create mode 100644 packages/cultmesh-browser/test/interop/content-https-reader.ts create mode 100644 packages/cultmesh-browser/test/interop/content-https.test.ts create mode 100644 tests/GameCult.Mesh.Tests/CultMeshHttpsContentInteropTests.cs diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index f53aae75..b7aaa935 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -117,9 +117,8 @@ jobs: - name: Verify the CultMesh authority rules with the TypeScript verifier run: npm run test --workspace packages/cultnet-ts - - name: Verify the browser client against the shared TypeScript verifier - run: npm run test --workspace packages/cultmesh-browser - + # cultmesh-browser's test compiles test/interop, which imports cultmesh-ts/content, so cultmesh-ts builds first, + # as in scripts/run-typescript-workspaces.mjs. # The realtime frame codec and the HTTP content chunk provider (content.test.ts, # its answers over node:http). The codec's byte parity rests on two vector files, # contracts/cultmesh/realtime-frame-vectors.json written by the C# @@ -130,6 +129,9 @@ jobs: - name: Verify the realtime frame codec and the content provider with the TypeScript runtime run: npm run test --workspace packages/cultmesh-ts + - name: Verify the browser client against the shared TypeScript verifier + run: npm run test --workspace packages/cultmesh-browser + # Both directions of the CultMesh authority vectors: the C#-written # file (contracts/cultmesh/authority-route-vectors.json) and the # TypeScript-signed file (authority-route-vectors.ts-signed.json) are @@ -161,3 +163,9 @@ jobs: dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj --filter FullyQualifiedName~CultMeshContentVectorTests --logger "console;verbosity=minimal" + + # The cross-process HTTPS content parity: the C# transfer service over the unmodified HTTPS connector and the + # TypeScript reader fetch from the TypeScript provider in its own process. Both refuse bytes tampered in flight, + # and the provider refuses a corrupt store. + - name: Verify HTTPS content parity across C#, the TypeScript provider and the TypeScript reader + run: npm run test:interop --workspace packages/cultmesh-browser diff --git a/packages/cultmesh-browser/package.json b/packages/cultmesh-browser/package.json index cd381854..6520e11a 100644 --- a/packages/cultmesh-browser/package.json +++ b/packages/cultmesh-browser/package.json @@ -20,7 +20,8 @@ "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", "prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false", - "test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js" + "test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js", + "test:interop": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test --test-force-exit dist-test/test/interop/*.test.js" }, "keywords": [ "cultmesh", @@ -45,6 +46,7 @@ }, "devDependencies": { "@types/node": "^24.0.0", + "cultmesh-ts": "^0.1.1", "typescript": "^5.9.0" } } diff --git a/packages/cultmesh-browser/test/interop/content-https-provider.ts b/packages/cultmesh-browser/test/interop/content-https-provider.ts new file mode 100644 index 00000000..e03e34a7 --- /dev/null +++ b/packages/cultmesh-browser/test/interop/content-https-provider.ts @@ -0,0 +1,42 @@ +// 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 { 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 say = (line: object): void => { process.stdout.write(`${JSON.stringify(line)}\n`); }; +let phase = "start"; + +const handlerFor = (mount: string, dir: string) => + createContentChunkHandler(createDirectoryChunkStore(dir), { onServerFault: fault => say({ phase, mount, fault: fault.kind }) }); + +const honest = handlerFor("honest", storeDir); +// In flight, after the provider verified the bytes: a 200 with a body comes back with byte 0 flipped and the headers unchanged. +const tampered = async (request: ContentChunkRequest): Promise => { + const answer = await honest(request); + if (answer.status !== 200 || !answer.body?.length) return answer; + const body = answer.body.slice(); + body[0]! ^= 1; + return { ...answer, body }; +}; +const mounts = { honest: toNodeListener(honest), 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; + if (!mount) { res.writeHead(404).end(); return; } + say({ phase, mount, url: req.url }); + mounts[mount](req, res); +}); + +await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); +const port = (server.address() as AddressInfo).port; +say({ status: "ready", endpoints: Object.fromEntries(Object.keys(mounts).map(mount => [mount, `https://127.0.0.1:${port}/${mount}/content`])) }); + +createInterface({ input: process.stdin }).on("line", line => { + const name = /^phase (.+)$/.exec(line)?.[1]; + if (name) { phase = name; say({ ack: name }); } +}).on("close", () => { server.closeAllConnections(); server.close(() => process.exit(0)); }); diff --git a/packages/cultmesh-browser/test/interop/content-https-reader.ts b/packages/cultmesh-browser/test/interop/content-https-reader.ts new file mode 100644 index 00000000..6b6ca52f --- /dev/null +++ b/packages/cultmesh-browser/test/interop/content-https-reader.ts @@ -0,0 +1,31 @@ +// The TypeScript reader in its own process, for content-https.test.ts. Argv: [only case]. +// One stdout line per case: {case, ok:true, sha256} (hashed here by node:crypto, not by the reader) or {case, ok:false, code, status}. +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { decodeManifest } from "cultnet-ts/content"; +import { fetchVerifiedChunk, fetchVerifiedContent, CultMeshContentError } from "../../src/content.js"; + +const [endpointsJson, manifestsDir, only] = process.argv.slice(2) as [string, string, string | undefined]; +const endpoints = JSON.parse(endpointsJson) as Record<"honest" | "tampered" | "corrupt", string>; +const manifest = (name: string) => decodeManifest(readFileSync(join(manifestsDir, `${name}.manifest`))); +const single = manifest("single"); + +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-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), +}; + +for (const [name, run] of Object.entries(cases)) { + if (only && only !== name) continue; + try { + console.log(JSON.stringify({ case: name, ok: true, sha256: createHash("sha256").update(await run()).digest("hex") })); + } catch (error) { + const e = error as CultMeshContentError; + console.log(JSON.stringify({ case: name, ok: false, code: e.code, status: e.status })); + } +} diff --git a/packages/cultmesh-browser/test/interop/content-https.test.ts b/packages/cultmesh-browser/test/interop/content-https.test.ts new file mode 100644 index 00000000..ef185dce --- /dev/null +++ b/packages/cultmesh-browser/test/interop/content-https.test.ts @@ -0,0 +1,147 @@ +// https-shape-parity across three OS processes. The TS provider (content-https-provider) serves over TLS; the TS reader +// (content-https-reader) and the C# transfer service over the unmodified HTTPS connector (CultMeshHttpsContentInteropTests) +// each fetch from it. Each consumer decides its own acceptance; this file only observes outcomes and judges what the provider saw. +// The provider's source is the cultmesh-ts package, so cultmesh-ts must be built first (scripts/run-typescript-workspaces.mjs order). + +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFile, execFileSync, spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createInterface } from "node:readline"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { EMPTY_SHA256, encodeManifest, packContent } from "cultnet-ts/content"; + +const execFileAsync = promisify(execFile); +const here = dirname(fileURLToPath(import.meta.url)); // dist-test/test/interop +const packageRoot = resolve(here, "../../.."); +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"); +const dotnet = process.env.DOTNET ?? "dotnet"; + +const haveDotnet = (() => { try { execFileSync(dotnet, ["--version"], { stdio: "ignore" }); return true; } catch { return false; } })(); +const skip = !haveDotnet && !process.env.CI; + +/** xorshift32 with a fixed seed: non-periodic, so every chunk of a multi-chunk body has its own hash (map F32). */ +function xorshiftBytes(length: number, seed: number): Uint8Array { + const bytes = new Uint8Array(length); + let state = seed >>> 0; + for (let i = 0; i < length; i++) { state ^= state << 13; state >>>= 0; state ^= state >>> 17; state ^= state << 5; state >>>= 0; bytes[i] = state & 255; } + return bytes; +} + +type Line = { phase?: string; mount?: string; url?: string; fault?: string; status?: string; endpoints?: Record; ack?: string }; +type ReaderResult = { case: string; ok: boolean; sha256?: string; code?: string; status?: number }; + +function startProvider(args: string[]) { + const child = spawn(process.execPath, [join(here, "content-https-provider.js"), ...args], { stdio: ["pipe", "pipe", "inherit"] }); + const lines: Line[] = []; + const waiting: Array<() => void> = []; + createInterface({ input: child.stdout }).on("line", text => { lines.push(JSON.parse(text) as Line); waiting.splice(0).forEach(wake => wake()); }); + const exited = new Promise(resolveExit => child.once("exit", code => resolveExit(code))); + const next = async (match: (line: Line) => boolean, what: string): Promise => { + const deadline = Date.now() + 30_000; + for (;;) { + const found = lines.find(match); + if (found) return found; + if (Date.now() > deadline) throw new Error(`provider did not report ${what} in 30 s`); + await new Promise(wake => { waiting.push(wake); setTimeout(wake, 250); }); + } + }; + return { child, lines, exited, next }; +} + +async function runDotnetTestFilter(projectPath: string, testName: string, env: NodeJS.ProcessEnv): Promise { + try { + const { stdout } = await execFileAsync(dotnet, ["test", projectPath, "--filter", `FullyQualifiedName~${testName}`, "--logger", "console;verbosity=normal", "-nologo"], + { cwd: repoRoot, env: { ...process.env, ...env }, timeout: 600_000 }); + return stdout; + } catch (error) { + const failure = error as { stdout?: string; stderr?: string; message: string }; + throw new Error(`dotnet test ${testName} failed.\nstdout:\n${failure.stdout ?? ""}\nstderr:\n${failure.stderr ?? failure.message}`); + } +} + +test("the TS provider serves both consumers: parity, refusal and the fault log", { skip, timeout: 900_000 }, async () => { + const root = mkdtempSync(join(tmpdir(), "cultmesh-content-https-")); + const store = join(root, "store"), corrupt = join(root, "corrupt"), manifests = join(root, "manifests"); + for (const dir of [store, corrupt, manifests]) mkdirSync(dir); + const createdAtUtc = "2026-10-19T00:00:00.0000000+00:00"; + 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 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); + 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)); + + const provider = startProvider([store, corrupt, keyPem, certPem]); + try { + const endpoints = (await provider.next(line => line.status === "ready", "ready")).endpoints!; + const endpointsJson = JSON.stringify(endpoints); + const setPhase = async (name: string) => { provider.child.stdin!.write(`phase ${name}\n`); await provider.next(line => line.ack === name, `ack ${name}`); }; + const requests = (phase: string, mount: string) => provider.lines.filter(line => line.phase === phase && line.mount === mount && line.url); + const faults = (phase: string, mount: string) => provider.lines.filter(line => line.phase === phase && line.mount === mount && line.fault); + const runReader = async (env: NodeJS.ProcessEnv, only?: string): Promise> => { + const { stdout } = await execFileAsync(process.execPath, [join(here, "content-https-reader.js"), endpointsJson, manifests, ...(only ? [only] : [])], { env }); + return Object.fromEntries(stdout.trim().split("\n").map(text => JSON.parse(text) as ReaderResult).map(result => [result.case, result])); + }; + const trusted: NodeJS.ProcessEnv = { ...process.env, NODE_EXTRA_CA_CERTS: certPem }; + delete trusted.NODE_TLS_REJECT_UNAUTHORIZED; + const untrusted: NodeJS.ProcessEnv = { ...trusted }; + delete untrusted.NODE_EXTRA_CA_CERTS; + + await setPhase("reader"); + const read = await runReader(trusted); + 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-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"); + assert.equal(read["corrupt-single"]?.ok, false); + assert.equal(read["corrupt-single"]?.code, "http-status"); + assert.equal(read["corrupt-single"]?.status, 500); + + await setPhase("untrusted"); + const refused = await runReader(untrusted, "honest-multi"); + assert.equal(refused["honest-multi"]?.ok, false); + assert.equal(refused["honest-multi"]?.code, "network"); + assert.equal(provider.lines.filter(line => line.phase === "untrusted" && line.url).length, 0, "TLS refused the reader before any HTTP request"); + + await setPhase("dotnet"); + const out = await runDotnetTestFilter("tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj", "CultMeshHttpsContentInteropTests", { + CULTMESH_HTTPS_CONTENT_ENDPOINT: endpoints.honest!, CULTMESH_HTTPS_CONTENT_TAMPERED_ENDPOINT: endpoints.tampered!, + CULTMESH_HTTPS_CONTENT_CORRUPT_ENDPOINT: endpoints.corrupt!, CULTMESH_HTTPS_CONTENT_MANIFESTS: manifests, CULTMESH_HTTPS_CONTENT_CA_PEM: certPem, + }); + assert.match(out, /Test Run Successful\./); + assert.match(out, /Passed:\s*3/); + assert.doesNotMatch(out, /Skipped:\s*[1-9]/); + + // (a) both consumers asked for the same four chunk URLs on the honest mount + 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()); + // (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}`))); + // (c) the provider refused the corrupt store for both consumers + for (const phase of ["reader", "dotnet"]) assert.ok(faults(phase, "corrupt").some(line => line.fault === "hash-mismatch"), `${phase} hit the corrupt chunk`); + // (d) no fault where the store is honest + for (const phase of ["reader", "untrusted", "dotnet"]) for (const mount of ["honest", "tampered"]) assert.equal(faults(phase, mount).length, 0, `${phase} ${mount}`); + } finally { + provider.child.stdin!.end(); + const killer = setTimeout(() => provider.child.kill(), 5_000); + await provider.exited; + clearTimeout(killer); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/GameCult.Mesh.Tests/CultMeshHttpsContentInteropTests.cs b/tests/GameCult.Mesh.Tests/CultMeshHttpsContentInteropTests.cs new file mode 100644 index 00000000..6f5850a1 --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshHttpsContentInteropTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using NUnit.Framework; + +namespace GameCult.Mesh.Tests; + +/// +/// The C# half of the cross-process HTTPS content parity. The provider is the TypeScript one, in another process; it is +/// started, and these tests are run, by packages/cultmesh-browser/test/interop/content-https.test.ts. Trust is the +/// fixture certificate through CustomRootTrust: chain and name validation stay on, and no other certificate is accepted. +/// +[TestFixture] +public sealed class CultMeshHttpsContentInteropTests +{ + private const string Prefix = "CULTMESH_HTTPS_CONTENT_"; + private string _dir = null!; + private string _manifests = null!; + private string _honest = null!, _tampered = null!, _corrupt = null!; + private X509Certificate2 _anchor = null!; + + [SetUp] + public void SetUp() + { + string? Var(string name) => Environment.GetEnvironmentVariable(Prefix + name); + var values = new[] { "ENDPOINT", "TAMPERED_ENDPOINT", "CORRUPT_ENDPOINT", "MANIFESTS", "CA_PEM" }.Select(Var).ToArray(); + if (values.Any(string.IsNullOrEmpty)) + Assert.Ignore("Run by packages/cultmesh-browser/test/interop/content-https.test.ts, which starts the TypeScript provider and sets " + Prefix + "*."); + (_honest, _tampered, _corrupt, _manifests) = (values[0]!, values[1]!, values[2]!, values[3]!); + _anchor = X509Certificate2.CreateFromPem(File.ReadAllText(values[4]!)); + _dir = Path.Combine(Path.GetTempPath(), "cultmesh-https-interop", Guid.NewGuid().ToString("N")); + } + + [TearDown] + public void TearDown() + { + if (_dir != null && Directory.Exists(_dir)) Directory.Delete(_dir, true); + } + + private CultMeshCdnArtifactManifest Manifest(string name) => + (CultMeshCdnArtifactManifest)CultDocumentMessagePackSerialization.DeserializeUntyped( + typeof(CultMeshCdnArtifactManifest), File.ReadAllBytes(Path.Combine(_manifests, name + ".manifest"))); + + private static readonly CultMeshSessionTarget Target = new("aetheria", "aetheria.daemon"); + + private sealed class Rig : IDisposable + { + public CultMeshDiscoveryService Discovery = null!; + public CultMeshSessionManager Sessions = null!; + public CultCache State = null!; + public CultMeshContentTransferService Transfer = null!; + public void Dispose() { State.Dispose(); Sessions.Dispose(); Discovery.Dispose(); } + } + + /// The production path: discovery, session manager with the unmodified HTTPS connector, session provider, transfer service. + private Rig Build(string endpoint, X509Certificate2 anchor, string cacheDir) + { + var rig = new Rig { Discovery = new CultMeshDiscoveryService(new[] { new RouteSource(endpoint) }) }; + rig.Sessions = new CultMeshSessionManager(rig.Discovery, Array.Empty(), + new ICultMeshContentTransportConnector[] + { + new CultMeshHttpsContentTransportConnector(() => new SocketsHttpHandler + { + SslOptions = new SslClientAuthenticationOptions + { + CertificateChainPolicy = new X509ChainPolicy + { + TrustMode = X509ChainTrustMode.CustomRootTrust, + CustomTrustStore = { anchor }, + RevocationMode = X509RevocationMode.NoCheck + } + } + }) + }, CultMeshTestTrust.LocalSessions); + var provider = new CultMeshSessionContentProvider("ts-https", rig.Sessions, Target); + rig.State = new CultCache(CultMesh.CreateCultCacheDocumentRegistry(typeof(CultMeshContentTransferStateDocument))); + rig.Transfer = new CultMeshContentTransferService(rig.State, new[] { provider }, new CultMeshContentTransferOptions(cacheDir)); + return rig; + } + + private static IEnumerable Chain(Exception e) + { + for (var current = e; current != null; current = current.InnerException) yield return current; + } + + private static string Sha(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant(); + + [Test] + public async Task HttpsTransferFetchesFromExternalTypeScriptProvider() + { + using var rig = Build(_honest, _anchor, _dir); + foreach (var name in new[] { "multi", "single", "empty" }) + { + var manifest = Manifest(name); + var path = await rig.Transfer.FetchAsync(manifest); + Sha(path).Should().Be(manifest.ContentHash.Replace("sha256:", ""), name); + if (name == "multi") manifest.Chunks.Should().HaveCount(4); + if (name == "empty") manifest.SizeBytes.Should().Be(0); + } + + var session = await rig.Sessions.ConnectContentAsync(Target); + var unknown = new CultMeshCdnChunkRef { ChunkHash = new string('0', 64), SizeBytes = 1 }; + var missing = async () => await session.CopyChunkToAsync(unknown, new MemoryStream()); + (await missing.Should().ThrowAsync()).Which.StatusCode.Should().Be(HttpStatusCode.NotFound); + + // The anchor is scoped: a different self-signed certificate does not validate the same server. + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=someone-else", key, HashAlgorithmName.SHA256); + using var stranger = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + using var other = Build(_honest, stranger, Path.Combine(_dir, "other")); + var refused = async () => await other.Transfer.FetchAsync(Manifest("single")); + var thrown = (await refused.Should().ThrowAsync()).Which; + Chain(thrown).Should().Contain(e => e is AuthenticationException); + } + + [Test] + public async Task HttpsTransferRefusesTamperedBytesFromExternalTypeScriptProvider() + { + using var rig = Build(_tampered, _anchor, _dir); + var multi = Manifest("multi"); + var fetch = async () => await rig.Transfer.FetchAsync(multi); + var thrown = (await fetch.Should().ThrowAsync()).Which; + Chain(thrown).Should().Contain(e => e is InvalidDataException && e.Message == "CDN artifact chunk hash does not match its manifest."); + (Directory.Exists(_dir) ? Directory.GetFiles(_dir, "*.body", SearchOption.AllDirectories) : Array.Empty()).Should().BeEmpty(); + + // The connector alone passes the wrong bytes: verification lives in the transfer owner. + var session = await rig.Sessions.ConnectContentAsync(Target); + using var bytes = new MemoryStream(); + await session.CopyChunkToAsync(multi.Chunks[0], bytes); + Convert.ToHexString(SHA256.HashData(bytes.ToArray())).ToLowerInvariant().Should().NotBe(multi.Chunks[0].ChunkHash.Replace("sha256:", "")); + } + + [Test] + public async Task HttpsTransferRefusesCorruptStoreFromExternalTypeScriptProvider() + { + using var rig = Build(_corrupt, _anchor, _dir); + var fetch = async () => await rig.Transfer.FetchAsync(Manifest("single")); + var thrown = (await fetch.Should().ThrowAsync()).Which; + Chain(thrown).OfType().Should().Contain(e => e.StatusCode == HttpStatusCode.InternalServerError); + (Directory.Exists(_dir) ? Directory.GetFiles(_dir, "*.body", SearchOption.AllDirectories) : Array.Empty()).Should().BeEmpty(); + } + + private sealed class RouteSource(string endpoint) : ICultMeshLookupSource + { + public string SourceId => "test"; + public Task> LookupAsync(CultMeshDiscoveryQuery query, CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + return Task.FromResult>(new[] + { + new CultMeshDiscoveryObservation(new CultMeshVerseDescriptor("aetheria", "Aetheria", + CultMeshVerseAuthorityModel.OperatorCluster, new CultMeshVerseCompatibility("cultmesh.v1", "rules"), + new[] { endpoint }, new[] { "aetheria.daemon" }), SourceId, now, now.AddMinutes(1), CultMeshDiscoveryTrust.Signed) + }); + } + } +}