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", "escape" })
{
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)
});
}
}
}