diff --git a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/HqInteropGetClient.kt b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/HqInteropGetClient.kt new file mode 100644 index 000000000..a1745efb0 --- /dev/null +++ b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/HqInteropGetClient.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quic.interop.runner + +import com.vitorpamplona.quic.connection.QuicConnection +import kotlinx.coroutines.flow.toList + +/** + * HQ-interop (HTTP/0.9 over QUIC) GET client. quic-interop-runner convention + * for the non-`http3` testcases (handshake / chacha20 / transfer / loss + * variants): the client opens a bidi stream, sends `GET /path\r\n` (raw + * ASCII, no framing), FINs the send side, and reads the response body + * verbatim until the server FINs. There is no status code, no headers, no + * QPACK, no control stream — the body bytes ARE the response. + * + * Per the runner's testcase validator, an empty body means failure, a + * non-empty body that matches what the server staged at $WWW/ means + * success. We surface non-empty as status=200, empty as status=0, so the + * caller's `if (resp.status != 200) anyFailed = true` check keeps working. + */ +class HqInteropGetClient( + private val conn: QuicConnection, +) : GetClient { + override suspend fun get( + @Suppress("UNUSED_PARAMETER") authority: String, + path: String, + ): GetResponse { + val stream = conn.openBidiStream() + val request = "GET $path\r\n".encodeToByteArray() + stream.send.enqueue(request) + stream.send.finish() + + val chunks = stream.incoming.toList() + val total = chunks.sumOf { it.size } + val body = ByteArray(total) + var off = 0 + for (c in chunks) { + c.copyInto(body, off) + off += c.size + } + return GetResponse(status = if (body.isEmpty()) 0 else 200, body = body) + } +} diff --git a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt index 19aae6820..40a03f212 100644 --- a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt +++ b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt @@ -31,9 +31,22 @@ import com.vitorpamplona.quic.qpack.QpackDecoder import com.vitorpamplona.quic.qpack.QpackEncoder import kotlinx.coroutines.flow.collect +/** Common shape for the two interop GET clients (HTTP/3 and HQ-interop). */ +interface GetClient { + suspend fun get( + authority: String, + path: String, + ): GetResponse +} + +data class GetResponse( + val status: Int, + val body: ByteArray, +) + /** * Minimal HTTP/3 GET client used by the interop endpoint to satisfy the - * `transfer`, `multiplexing`, and `http3` testcases. + * `http3` and `multiplexing` testcases. * * Opens the three required client-side unidirectional streams (control, * QPACK encoder, QPACK decoder) per RFC 9114 §6.2.1. Encodes requests with @@ -45,7 +58,7 @@ import kotlinx.coroutines.flow.collect */ class Http3GetClient( private val conn: QuicConnection, -) { +) : GetClient { suspend fun init() { // Control stream: type-0x00 prefix followed by a SETTINGS frame // (empty body is legal — RFC 9114 §7.2.4). @@ -74,10 +87,10 @@ class Http3GetClient( * Issue a GET on a fresh bidi stream and return the parsed response. * Suspends until the server FINs the response stream. */ - suspend fun get( + override suspend fun get( authority: String, path: String, - ): Response { + ): GetResponse { val stream = conn.openBidiStream() stream.send.enqueue(encodeRequest(authority, path)) stream.send.finish() @@ -105,13 +118,8 @@ class Http3GetClient( } } } - return Response(status = status, body = concat(body)) + return GetResponse(status = status, body = concat(body)) } - - data class Response( - val status: Int, - val body: ByteArray, - ) } /** diff --git a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt index a0523cecc..5597a1d33 100644 --- a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt +++ b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt @@ -89,21 +89,34 @@ fun main() { else -> null } + // ALPN per quic-interop-runner convention. Most testcases use + // `hq-interop` (HTTP/0.9 over QUIC, no H3/QPACK). Only the `http3` and + // `multiplexing` testcases use `h3`. quic-go-qns enforces this strictly + // (CRYPTO_ERROR 0x178 / TLS no_application_protocol on mismatch); + // aioquic and picoquic accept either. Match the per-test convention + // so we work everywhere. + val alpn = + when (testcase) { + "http3", "multiplexing" -> Alpn.H3 + else -> Alpn.HQ_INTEROP + } + val code = when (testcase) { - // The runner's `handshake` / `chacha20` / `handshakeloss` testcases - // require BOTH a successful handshake AND the requested file - // transferred to /downloads — the validator checks file presence - // via _check_files() AND that exactly 1 handshake happened on the - // wire via _count_handshakes(). `chacha20` adds the constraint - // that we offered only ChaCha20-Poly1305 (verified by the runner - // via tshark on the sim's pcap decrypted using SSLKEYLOGFILE). - // `handshakeloss` is the same client behaviour against a lossy sim. - // - // `transfer` / `http3` / `multiplexing` and the network-condition - // aliases (transferloss / transfercorruption / longrtt / goodput - // / crosstraffic) run the same H3 GET pipeline; the runner just - // varies the sim configuration around them. + // All these testcases require: successful handshake + file + // transferred to /downloads. Per-testcase notes: + // chacha20 — runner verifies the negotiated cipher + // was ChaCha20-Poly1305 via tshark on the + // pcap (decrypted with SSLKEYLOGFILE). + // handshakeloss/ — same client behaviour against the + // transferloss runner's sim with random packet loss. + // transfercorruption — random bit-flips (recovery via + // AEAD AUTH FAIL → drop + retransmit). + // longrtt — emulated high-latency link. + // goodput / crosstraffic — throughput-floor / competing-flow + // scenarios. + // multiplexing — H3 GETs issued in parallel; runner + // verifies overlap on the wire via tshark. "handshake", "chacha20", "handshakeloss", "transfer", "http3", "multiplexing", "transferloss", "transfercorruption", "longrtt", "goodput", "crosstraffic", @@ -112,6 +125,7 @@ fun main() { requests = requests, downloadsDir = downloadsDir, cipherSuites = cipherSuites, + alpn = alpn, keyLogPath = keyLogPath, parallel = (testcase == "multiplexing"), ) @@ -124,10 +138,24 @@ fun main() { exitProcess(code) } +internal enum class Alpn( + val wireBytes: ByteArray, +) { + /** RFC 9114 — full HTTP/3 + QPACK + H3 framing. */ + H3("h3".encodeToByteArray()), + + /** quic-interop-runner convention — HTTP/0.9 over QUIC. Just `GET /path\r\n` + * on a fresh bidi stream, server returns the body, FIN both sides. No + * control stream, no QPACK, no SETTINGS. Used for handshake / chacha20 / + * transfer / loss-variant testcases. */ + HQ_INTEROP("hq-interop".encodeToByteArray()), +} + private fun runTransferTest( requests: String, downloadsDir: File, cipherSuites: IntArray?, + alpn: Alpn, keyLogPath: String?, parallel: Boolean, ): Int { @@ -163,6 +191,7 @@ private fun runTransferTest( serverName = host, config = QuicConnectionConfig(), tlsCertificateValidator = PermissiveCertificateValidator(), + alpnList = listOf(alpn.wireBytes), cipherSuites = cipherSuites ?: intArrayOf( @@ -184,8 +213,11 @@ private fun runTransferTest( return@runBlocking "handshake_failed" } - val client = Http3GetClient(conn) - client.init() + val client: GetClient = + when (alpn) { + Alpn.H3 -> Http3GetClient(conn).also { it.init() } + Alpn.HQ_INTEROP -> HqInteropGetClient(conn) + } val authority = if (port == 443) host else "$host:$port" val outcome =