diff --git a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp index 8da12aed..bbb6e193 100644 --- a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp +++ b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -58,8 +59,22 @@ struct Client { std::mutex gate; std::deque> received; std::string error; + std::string trace; }; +void AppendTrace(Client* client, const std::string& event) { + if (client == nullptr || client->state.load() == ClientState::Connected) return; + std::lock_guard lock(client->gate); + if (!client->trace.empty()) client->trace += ","; + client->trace += event; +} + +std::string TraceSnapshot(Client* client) { + if (client == nullptr) return "none"; + std::lock_guard lock(client->gate); + return client->trace.empty() ? "none" : client->trace; +} + void SetFailure(Client* client, const std::string& message) { if (client == nullptr || client->closing.load()) return; { @@ -184,8 +199,30 @@ bool CertificateMatchesPin(Client* client, QUIC_CERTIFICATE* certificate) { return std::memcmp(digest, client->certificate_pin.data(), sizeof(digest)) == 0; } +std::string Hex(uint64_t value) { + std::ostringstream text; + text << "0x" << std::hex << std::uppercase << value; + return text.str(); +} + +std::string LoadedMsQuicPath() { + const auto module = GetModuleHandleW(L"msquic.dll"); + if (module == nullptr) return "unresolved"; + std::array path{}; + const auto length = GetModuleFileNameW(module, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size()) return "unresolved"; + const auto required = WideCharToMultiByte( + CP_UTF8, 0, path.data(), static_cast(length), nullptr, 0, nullptr, nullptr); + if (required <= 0) return "unresolved"; + std::string utf8(static_cast(required), '\0'); + WideCharToMultiByte( + CP_UTF8, 0, path.data(), static_cast(length), utf8.data(), required, nullptr, nullptr); + return utf8; +} + QUIC_STATUS QUIC_API ConnectionCallback(HQUIC connection, void* context, QUIC_CONNECTION_EVENT* event) { auto* client = static_cast(context); + AppendTrace(client, "event=" + std::to_string(static_cast(event->Type))); switch (event->Type) { case QUIC_CONNECTION_EVENT_CONNECTED: client->state.store(ClientState::Connected); @@ -200,14 +237,40 @@ QUIC_STATUS QUIC_API ConnectionCallback(HQUIC connection, void* context, QUIC_CO break; } case QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED: - if (!CertificateMatchesPin(client, event->PEER_CERTIFICATE_RECEIVED.Certificate)) { + { + AppendTrace( + client, + "certificate-status=" + + Hex(static_cast(event->PEER_CERTIFICATE_RECEIVED.DeferredStatus))); + const auto matches = CertificateMatchesPin( + client, + event->PEER_CERTIFICATE_RECEIVED.Certificate); + if (!matches) { SetFailure(client, "CultMesh QUIC provider certificate does not match the advertised SHA-256 pin."); - return QUIC_STATUS_BAD_CERTIFICATE; } - return QUIC_STATUS_SUCCESS; + const auto completion = client->api->ConnectionCertificateValidationComplete( + connection, + matches ? TRUE : FALSE, + matches ? QUIC_TLS_ALERT_CODE_SUCCESS : QUIC_TLS_ALERT_CODE_BAD_CERTIFICATE); + if (QUIC_FAILED(completion)) { + SetFailure( + client, + "CultMesh QUIC certificate validation completion failed (status=" + + Hex(static_cast(completion)) + ")."); + return completion; + } + return QUIC_STATUS_PENDING; + } case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT: - if (!client->closing.load()) - SetFailure(client, "CultMesh QUIC connection was shut down by the transport."); + if (!client->closing.load()) { + SetFailure( + client, + "CultMesh QUIC connection was shut down by the transport " + "(status=" + Hex(static_cast(event->SHUTDOWN_INITIATED_BY_TRANSPORT.Status)) + + ", error=" + Hex(event->SHUTDOWN_INITIATED_BY_TRANSPORT.ErrorCode) + + ", msquic=" + LoadedMsQuicPath() + + ", events=" + TraceSnapshot(client) + ")."); + } break; case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER: if (!client->closing.load()) diff --git a/src/GameCult.Mesh/docs/transport-planes.md b/src/GameCult.Mesh/docs/transport-planes.md index c1372105..314c55b4 100644 --- a/src/GameCult.Mesh/docs/transport-planes.md +++ b/src/GameCult.Mesh/docs/transport-planes.md @@ -94,9 +94,18 @@ authentication to provider authorization. Test-only trust-all callbacks are not a release configuration. A trusted discovery record may carry `cert-sha256=` on its QUIC route; absent a caller validator, the connector accepts a self-signed provider certificate only when that pin matches. +The native client uses MsQuic's deferred-certificate contract: it completes the +pin decision through `ConnectionCertificateValidationComplete` and returns +`QUIC_STATUS_PENDING` from the certificate event. Transport failures report the +MsQuic status, wire error, loaded runtime path, and pre-connect event sequence. ## Local parity evidence +The native suite can also connect to an independently running managed provider +by setting `CULTMESH_NATIVE_EXTERNAL_ENDPOINT` to its advertised pinned QUIC +route. This keeps the managed server and native client in separate processes, +so one process-local MsQuic DLL cannot counterfeit cross-runtime parity. + On 2026-07-19, the parity harness moved a 13 MiB payload on loopback using 1 MiB content chunks. Raw TCP reached 127.4 MiB/s, verified CultMesh TCP content reached 59.0 MiB/s, raw MsQuic reached 51.5 MiB/s, and legacy CultMesh RUDP diff --git a/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs b/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs index c12cae03..afa74426 100644 --- a/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs +++ b/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs @@ -12,6 +12,26 @@ namespace GameCult.Mesh.Quic.Native.Tests; [TestFixture] public sealed class CultMeshNativeQuicRealtimeTransportTests { + [Test] + public async Task NativeConnectorReceivesFromExternalManagedProvider() + { + var endpoint = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_ENDPOINT"); + if (string.IsNullOrWhiteSpace(endpoint)) + Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_ENDPOINT to exercise cross-process native/managed parity."); + + var connector = new CultMeshNativeQuicRealtimeTransportConnector(); + using var client = await connector.ConnectAsync( + new CultMeshTransportCandidate(endpoint!), + CultMeshEndpointId.Parse("aetheria.local")); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var frame = await client.ReceiveAsync(timeout.Token); + + client.TransportId.Should().Be("msquic-native-realtime"); + frame.SchemaId.Should().NotBeNullOrWhiteSpace(); + frame.BodyId.Should().NotBeNullOrWhiteSpace(); + frame.Payload.Should().NotBeEmpty(); + } + [Test] public async Task NativeConnectorAuthenticatesAndReceivesLatestOnlyFrame() {