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 <noreply@anthropic.com>
This commit is contained in:
parent
cebe6ae724
commit
424913bfb7
6 changed files with 403 additions and 4 deletions
14
.github/workflows/cultmesh-portability.yml
vendored
14
.github/workflows/cultmesh-portability.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
// The TypeScript content provider in its own process, for content-https.test.ts. Argv: <store dir> <corrupt store dir> <key pem> <cert pem>.
|
||||
// Protocol, stdout JSON lines only: ready, one line per request ({phase, mount, url}), one per server fault ({phase, mount, fault}).
|
||||
// Stdin: "phase <name>" 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<ContentChunkAnswer> => {
|
||||
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<void>(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)); });
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// The TypeScript reader in its own process, for content-https.test.ts. Argv: <endpoints json> <manifests dir> [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<string, () => Promise<Uint8Array>> = {
|
||||
"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 }));
|
||||
}
|
||||
}
|
||||
147
packages/cultmesh-browser/test/interop/content-https.test.ts
Normal file
147
packages/cultmesh-browser/test/interop/content-https.test.ts
Normal file
|
|
@ -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<string, string>; 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<number | null>(resolveExit => child.once("exit", code => resolveExit(code)));
|
||||
const next = async (match: (line: Line) => boolean, what: string): Promise<Line> => {
|
||||
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<void>(wake => { waiting.push(wake); setTimeout(wake, 250); });
|
||||
}
|
||||
};
|
||||
return { child, lines, exited, next };
|
||||
}
|
||||
|
||||
async function runDotnetTestFilter(projectPath: string, testName: string, env: NodeJS.ProcessEnv): Promise<string> {
|
||||
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<Record<string, ReaderResult>> => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
169
tests/GameCult.Mesh.Tests/CultMeshHttpsContentInteropTests.cs
Normal file
169
tests/GameCult.Mesh.Tests/CultMeshHttpsContentInteropTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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(); }
|
||||
}
|
||||
|
||||
/// <summary>The production path: discovery, session manager with the unmodified HTTPS connector, session provider, transfer service.</summary>
|
||||
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<ICultMeshTransportConnector>(),
|
||||
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<Exception> 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<HttpRequestException>()).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<Exception>()).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<AggregateException>()).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<string>()).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<Exception>()).Which;
|
||||
Chain(thrown).OfType<HttpRequestException>().Should().Contain(e => e.StatusCode == HttpStatusCode.InternalServerError);
|
||||
(Directory.Exists(_dir) ? Directory.GetFiles(_dir, "*.body", SearchOption.AllDirectories) : Array.Empty<string>()).Should().BeEmpty();
|
||||
}
|
||||
|
||||
private sealed class RouteSource(string endpoint) : ICultMeshLookupSource
|
||||
{
|
||||
public string SourceId => "test";
|
||||
public Task<IReadOnlyList<CultMeshDiscoveryObservation>> LookupAsync(CultMeshDiscoveryQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return Task.FromResult<IReadOnlyList<CultMeshDiscoveryObservation>>(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)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue