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:
Meta Vens 2026-10-19 19:45:10 +02:00
commit 424913bfb7
6 changed files with 403 additions and 4 deletions

View file

@ -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

View file

@ -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"
}
}

View file

@ -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)); });

View file

@ -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 }));
}
}

View 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 });
}
});

View 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)
});
}
}
}