From 2d541c6fd46d354b93f672f8874ef014c1bcc3ce Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 17:19:02 +0000 Subject: [PATCH] =?UTF-8?q?feat(quic):=20Phase=20A=20=E2=80=94=20module=20?= =?UTF-8?q?foundations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create the new :quic Gradle module (KMP, api(project(":quartz"))) and migrate the QUIC varint codec out of :nestsClient where it was incidentally living. Add the connection-ID, packet-number-space, and UDP socket primitives that the rest of the QUIC client will build on. Layer-by-layer plan in docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md. - New :quic module wired into settings.gradle, with commonMain + jvmAndroid source sets mirroring :quartz's structure. - Varint moves from com.vitorpamplona.nestsclient.moq to com.vitorpamplona.quic; MoqBuffer/MoqCodec updated to import the new path. - ConnectionId enforces the 0..20 byte length range and ships a randomizer backed by Quartz's RandomInstance. - PacketNumberSpaceState tracks per-space outbound allocation + largest-received tracking, and implements the RFC 9000 §A.3 truncated-PN decode formula plus the §17.1 minimum encode-length picker. - UdpSocket is an expect class with a connected DatagramChannel actual on jvmAndroid using Dispatchers.IO (no Selector — one socket per connection). All 12 tests pass on jvmTest. RFC 9000 §A.1 varint vectors and §A.3 truncated-PN vector match bit-for-bit. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx --- nestsClient/build.gradle.kts | 1 + .../nestsclient/moq/MoqBuffer.kt | 2 + .../vitorpamplona/nestsclient/moq/MoqCodec.kt | 1 + quic/build.gradle.kts | 98 ++++++++++++++ .../kotlin/com/vitorpamplona/quic}/Varint.kt | 6 +- .../quic/connection/ConnectionId.kt | 59 +++++++++ .../quic/connection/PacketNumberSpace.kt | 124 ++++++++++++++++++ .../vitorpamplona/quic/transport/UdpSocket.kt | 59 +++++++++ .../com/vitorpamplona/quic}/VarintTest.kt | 14 +- .../quic/connection/ConnectionIdTest.kt | 68 ++++++++++ .../quic/connection/PacketNumberSpaceTest.kt | 98 ++++++++++++++ .../vitorpamplona/quic/transport/UdpSocket.kt | 105 +++++++++++++++ settings.gradle | 1 + 13 files changed, 620 insertions(+), 16 deletions(-) create mode 100644 quic/build.gradle.kts rename {nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq => quic/src/commonMain/kotlin/com/vitorpamplona/quic}/Varint.kt (96%) create mode 100644 quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/ConnectionId.kt create mode 100644 quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PacketNumberSpace.kt create mode 100644 quic/src/commonMain/kotlin/com/vitorpamplona/quic/transport/UdpSocket.kt rename {nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq => quic/src/commonTest/kotlin/com/vitorpamplona/quic}/VarintTest.kt (89%) create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/ConnectionIdTest.kt create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PacketNumberSpaceTest.kt create mode 100644 quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/transport/UdpSocket.kt diff --git a/nestsClient/build.gradle.kts b/nestsClient/build.gradle.kts index 59e6d1f9d8..4818c3915b 100644 --- a/nestsClient/build.gradle.kts +++ b/nestsClient/build.gradle.kts @@ -38,6 +38,7 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) api(project(":quartz")) + implementation(project(":quic")) } } diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqBuffer.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqBuffer.kt index 9786c88a2b..d1b9e9f725 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqBuffer.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqBuffer.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.nestsclient.moq +import com.vitorpamplona.quic.Varint + /** * Append-only byte buffer used by the MoQ encoders. Doubles in capacity when * full. Kept intentionally minimal so this module has no buffer-library diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt index 6317114a15..a1824ada25 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/MoqCodec.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.nestsclient.moq import com.vitorpamplona.nestsclient.moq.MoqCodec.encode +import com.vitorpamplona.quic.Varint /** * Encode/decode MoQ control-stream messages per draft-ietf-moq-transport. diff --git a/quic/build.gradle.kts b/quic/build.gradle.kts new file mode 100644 index 0000000000..e93f0942af --- /dev/null +++ b/quic/build.gradle.kts @@ -0,0 +1,98 @@ +/* + * 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. + */ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKotlinMultiplatformLibrary) +} + +kotlin { + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + android { + namespace = "com.vitorpamplona.quic" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + + withHostTest {} + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) + api(project(":quartz")) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } + + val jvmAndroid = + create("jvmAndroid") { + dependsOn(commonMain.get()) + } + + jvmMain { + dependsOn(jvmAndroid) + } + + androidMain { + dependsOn(jvmAndroid) + } + + jvmTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.secp256k1.kmp.jni.jvm) + } + } + + getByName("androidHostTest") { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.secp256k1.kmp.jni.jvm) + } + } + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/Varint.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/Varint.kt similarity index 96% rename from nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/Varint.kt rename to quic/src/commonMain/kotlin/com/vitorpamplona/quic/Varint.kt index c4f1f203c4..f9007fe12d 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/Varint.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/Varint.kt @@ -18,9 +18,7 @@ * 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.nestsclient.moq - -import com.vitorpamplona.nestsclient.moq.Varint.size +package com.vitorpamplona.quic /** * QUIC variable-length integer codec, per RFC 9000 §16. @@ -31,7 +29,7 @@ import com.vitorpamplona.nestsclient.moq.Varint.size * 10 → 4 bytes, 30-bit value (0 .. 1_073_741_823) * 11 → 8 bytes, 62-bit value (0 .. 4_611_686_018_427_387_903) * - * MoQ messages, parameters, and most length prefixes use this encoding. + * Used by QUIC frame and packet framing, by HTTP/3, by QPACK, and by MoQ. */ object Varint { const val MAX_VALUE: Long = (1L shl 62) - 1 diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/ConnectionId.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/ConnectionId.kt new file mode 100644 index 0000000000..32efb3aaf3 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/ConnectionId.kt @@ -0,0 +1,59 @@ +/* + * 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.connection + +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * QUIC connection identifier per RFC 9000 §5.1. + * + * Length must be 0..20. Zero-length CIDs are legal but only the peer that + * issued them ever uses them; we always issue at least 8 bytes for our source + * IDs so the server can route packets back even after path migration (which + * we don't initiate, but they do happen on roaming clients). + */ +class ConnectionId( + val bytes: ByteArray, +) { + val length: Int get() = bytes.size + + init { + require(bytes.size in 0..20) { "CID length out of range: ${bytes.size}" } + } + + fun toHex(): String = bytes.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } + + override fun equals(other: Any?): Boolean = other is ConnectionId && bytes.contentEquals(other.bytes) + + override fun hashCode(): Int = bytes.contentHashCode() + + override fun toString(): String = "ConnectionId(${toHex()})" + + companion object { + /** Generate a random CID of the given length using [RandomInstance]. */ + fun random(length: Int = 8): ConnectionId { + require(length in 0..20) { "CID length out of range: $length" } + return ConnectionId(RandomInstance.bytes(length)) + } + + val EMPTY = ConnectionId(ByteArray(0)) + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PacketNumberSpace.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PacketNumberSpace.kt new file mode 100644 index 0000000000..d5966309ac --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PacketNumberSpace.kt @@ -0,0 +1,124 @@ +/* + * 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.connection + +/** + * The three QUIC packet-number spaces per RFC 9000 §12.3. + * + * Each space has independent monotonically-increasing packet numbers, + * independent ACK state, and is protected with a different set of keys. + */ +enum class PacketNumberSpace { + INITIAL, + HANDSHAKE, + APPLICATION, +} + +/** + * Per-space outbound packet-number generator and inbound largest-received tracker. + * + * RFC 9000 §17.1: packet numbers in a space MUST start at 0 and increase by + * exactly 1 for each packet sent. Packet numbers MUST NOT exceed 2^62 - 1 in a + * single connection, but in practice we run out of bytes long before then. + */ +class PacketNumberSpaceState { + /** Next packet number to assign on send. RFC 9000 §17.1 — starts at 0. */ + var nextPacketNumber: Long = 0L + private set + + /** Largest packet number we've received that decrypted successfully. */ + var largestReceived: Long = -1L + private set + + /** Time (ms since epoch) the [largestReceived] packet was received. */ + var largestReceivedTime: Long = 0L + private set + + /** Allocate the next outbound packet number. */ + fun allocateOutbound(): Long = nextPacketNumber++ + + /** Note that an inbound packet was successfully decrypted. */ + fun observeInbound( + packetNumber: Long, + receivedAtMillis: Long, + ) { + if (packetNumber > largestReceived) { + largestReceived = packetNumber + largestReceivedTime = receivedAtMillis + } + } + + /** + * Decode a truncated wire packet number using RFC 9000 Appendix A.3. + * + * `expectedPn` is `largestReceived + 1` (or 0 when nothing has been + * received yet). `truncatedPn` is the value that was on the wire after + * removing header protection. `pnLen` is the wire length in bytes (1..4). + */ + fun decodePacketNumber( + truncatedPn: Long, + pnLen: Int, + ): Long = decodePacketNumber(largestReceived, truncatedPn, pnLen) + + companion object { + fun decodePacketNumber( + largestReceived: Long, + truncatedPn: Long, + pnLen: Int, + ): Long { + val expected = largestReceived + 1 + val pnNbits = pnLen * 8 + val pnWin = 1L shl pnNbits + val pnHwin = pnWin / 2 + val pnMask = pnWin - 1 + val candidate = (expected and pnMask.inv()) or truncatedPn + return when { + candidate <= expected - pnHwin && candidate < (1L shl 62) - pnWin -> candidate + pnWin + candidate > expected + pnHwin && candidate >= pnWin -> candidate - pnWin + else -> candidate + } + } + + /** + * Choose the minimum number of bytes needed to encode [packetNumber] + * given the largest acked packet — RFC 9000 §17.1 / §A.2. + * + * The encoded length must satisfy `2^(8*len - 1) >= num_unacked`, + * which gives the table: + * num_unacked ≤ 128 → 1 byte + * num_unacked ≤ 32768 → 2 bytes + * num_unacked ≤ 8388608 → 3 bytes + * otherwise → 4 bytes + */ + fun encodeLength( + packetNumber: Long, + largestAcked: Long, + ): Int { + val numUnacked = if (largestAcked < 0) packetNumber + 1 else packetNumber - largestAcked + return when { + numUnacked <= 128L -> 1 + numUnacked <= 32_768L -> 2 + numUnacked <= 8_388_608L -> 3 + else -> 4 + } + } + } +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/transport/UdpSocket.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/transport/UdpSocket.kt new file mode 100644 index 0000000000..c60e280a2b --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/transport/UdpSocket.kt @@ -0,0 +1,59 @@ +/* + * 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.transport + +/** + * Connected UDP socket abstraction used by the QUIC connection. + * + * The socket is bound to an ephemeral local port and connected to one remote + * peer. We do not support unconnected sockets, multipath, or address migration + * — the QUIC connection uses exactly one 4-tuple for its lifetime. + * + * Implementations: + * - jvmAndroid: NIO `DatagramChannel` wrapped in suspend functions. + * - native (future): platform `socket()` / `recv()` / `send()` syscalls. + */ +expect class UdpSocket { + /** Send one datagram to the connected peer. Returns the number of bytes written. */ + suspend fun send(payload: ByteArray): Int + + /** + * Receive one datagram from the connected peer. Suspends until either a + * packet arrives or the socket is closed. Returns null on close. + * + * The returned ByteArray is freshly allocated for each call. + */ + suspend fun receive(): ByteArray? + + /** Close the socket. After close, [receive] returns null and [send] throws. */ + fun close() + + /** Local port the OS assigned to the socket. */ + val localPort: Int + + companion object { + /** Open a UDP socket connected to [host]:[port]. Throws on resolution / bind / connect failure. */ + suspend fun connect( + host: String, + port: Int, + ): UdpSocket + } +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/VarintTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/VarintTest.kt similarity index 89% rename from nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/VarintTest.kt rename to quic/src/commonTest/kotlin/com/vitorpamplona/quic/VarintTest.kt index e92d0fdcd0..8110eef135 100644 --- a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/VarintTest.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/VarintTest.kt @@ -18,7 +18,7 @@ * 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.nestsclient.moq +package com.vitorpamplona.quic import kotlin.test.Test import kotlin.test.assertContentEquals @@ -27,13 +27,8 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNull class VarintTest { - /** - * RFC 9000 §A.1 sample encodings, which every QUIC varint implementation - * must reproduce bit-for-bit. - */ @Test fun rfc9000_sample_151288809941952652() { - // 62-bit: 151288809941952652 == 0x2136 0x0000 0000 8c4c (encoded) val value = 151288809941952652L val encoded = Varint.encode(value) assertContentEquals( @@ -73,11 +68,7 @@ class VarintTest { fun boundary_values_round_trip() { for (v in listOf(0L, 63L, 64L, 16_383L, 16_384L, 1_073_741_823L, 1_073_741_824L, Varint.MAX_VALUE)) { val encoded = Varint.encode(v) - assertEquals( - v, - Varint.decode(encoded)!!.value, - "round-trip for $v", - ) + assertEquals(v, Varint.decode(encoded)!!.value, "round-trip for $v") } } @@ -101,7 +92,6 @@ class VarintTest { @Test fun short_buffer_returns_null_so_caller_can_buffer_more() { assertNull(Varint.decode(ByteArray(0))) - // 4-byte varint with only 3 bytes available: assertNull(Varint.decode(byteArrayOf(0x9D.toByte(), 0x7F.toByte(), 0x3E), 0)) } diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/ConnectionIdTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/ConnectionIdTest.kt new file mode 100644 index 0000000000..38ebe40a3b --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/ConnectionIdTest.kt @@ -0,0 +1,68 @@ +/* + * 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.connection + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals + +class ConnectionIdTest { + @Test + fun random_default_length_is_8() { + assertEquals(8, ConnectionId.random().length) + } + + @Test + fun random_returns_distinct_ids() { + val a = ConnectionId.random() + val b = ConnectionId.random() + assertNotEquals(a, b) + } + + @Test + fun length_zero_is_legal() { + val id = ConnectionId(ByteArray(0)) + assertEquals(0, id.length) + assertEquals("", id.toHex()) + } + + @Test + fun length_over_20_is_rejected() { + assertFailsWith { ConnectionId(ByteArray(21)) } + } + + @Test + fun equals_compares_bytes() { + val a = ConnectionId(byteArrayOf(0x01, 0x02, 0x03)) + val b = ConnectionId(byteArrayOf(0x01, 0x02, 0x03)) + val c = ConnectionId(byteArrayOf(0x01, 0x02, 0x04)) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + assertNotEquals(a, c) + } + + @Test + fun toHex_lowercase_padded() { + val id = ConnectionId(byteArrayOf(0x00, 0x0A, 0xFF.toByte())) + assertEquals("000aff", id.toHex()) + } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PacketNumberSpaceTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PacketNumberSpaceTest.kt new file mode 100644 index 0000000000..dc2ca9a355 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/PacketNumberSpaceTest.kt @@ -0,0 +1,98 @@ +/* + * 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.connection + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PacketNumberSpaceTest { + @Test + fun rfc9000_a3_decode_example() { + // RFC 9000 Appendix A.3: largest received = 0xa82f30ea, truncated wire = 0x9b32, len=2 + // Expected decoded value: 0xa82f9b32 + assertEquals( + 0xa82f9b32L, + PacketNumberSpaceState.decodePacketNumber( + largestReceived = 0xa82f30eaL, + truncatedPn = 0x9b32L, + pnLen = 2, + ), + ) + } + + @Test + fun decode_first_packet_starts_at_truncated() { + // No packets received → largestReceived = -1 → expected pn = 0 + // Server sends pn=0, on the wire as 1-byte 0x00 + assertEquals( + 0L, + PacketNumberSpaceState.decodePacketNumber( + largestReceived = -1L, + truncatedPn = 0L, + pnLen = 1, + ), + ) + // Then pn=1 + assertEquals( + 1L, + PacketNumberSpaceState.decodePacketNumber( + largestReceived = 0L, + truncatedPn = 1L, + pnLen = 1, + ), + ) + } + + @Test + fun outbound_allocation_is_monotonic() { + val s = PacketNumberSpaceState() + assertEquals(0L, s.allocateOutbound()) + assertEquals(1L, s.allocateOutbound()) + assertEquals(2L, s.allocateOutbound()) + assertEquals(3L, s.nextPacketNumber) + } + + @Test + fun inbound_observation_tracks_max() { + val s = PacketNumberSpaceState() + s.observeInbound(5L, 100L) + assertEquals(5L, s.largestReceived) + s.observeInbound(3L, 200L) + assertEquals(5L, s.largestReceived) // out of order, no update + assertEquals(100L, s.largestReceivedTime) + s.observeInbound(7L, 300L) + assertEquals(7L, s.largestReceived) + assertEquals(300L, s.largestReceivedTime) + } + + @Test + fun encodeLength_picks_minimum() { + // First packet, no acks: needs at least 1 byte + assertEquals(1, PacketNumberSpaceState.encodeLength(0L, -1L)) + assertEquals(1, PacketNumberSpaceState.encodeLength(127L, -1L)) + // 2 bytes when 8-bit window not enough + assertEquals(2, PacketNumberSpaceState.encodeLength(256L, -1L)) + // 3 bytes when 16-bit window not enough (needs 17+ bits for 2× margin) + assertEquals(3, PacketNumberSpaceState.encodeLength(0xFFFFL, 0L)) + // 4 bytes for very large gaps + assertEquals(4, PacketNumberSpaceState.encodeLength(0x80_00_00_00L, -1L)) + } +} diff --git a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/transport/UdpSocket.kt b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/transport/UdpSocket.kt new file mode 100644 index 0000000000..3c7c178ad4 --- /dev/null +++ b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/transport/UdpSocket.kt @@ -0,0 +1,105 @@ +/* + * 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.transport + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.InetAddress +import java.net.InetSocketAddress +import java.nio.ByteBuffer +import java.nio.channels.ClosedChannelException +import java.nio.channels.DatagramChannel +import java.util.concurrent.atomic.AtomicBoolean + +/** + * JVM/Android UDP socket using blocking [DatagramChannel] dispatched onto + * [Dispatchers.IO]. We don't use NIO selectors because each QUIC connection + * has exactly one socket and one receive loop — Selector doesn't pay for + * itself at this scale. + * + * The receive buffer is sized to 64 KiB (max IPv4/IPv6 datagram); QUIC packets + * cap at MTU (~1500 in practice). + */ +actual class UdpSocket private constructor( + private val channel: DatagramChannel, + private val remote: InetSocketAddress, +) { + private val closed = AtomicBoolean(false) + private val readBuf = ByteBuffer.allocate(MAX_DATAGRAM_SIZE) + + actual val localPort: Int + get() = (channel.localAddress as InetSocketAddress).port + + actual suspend fun send(payload: ByteArray): Int = + withContext(Dispatchers.IO) { + if (closed.get()) throw ClosedChannelException() + val buf = ByteBuffer.wrap(payload) + channel.send(buf, remote) + } + + actual suspend fun receive(): ByteArray? = + withContext(Dispatchers.IO) { + if (closed.get()) return@withContext null + try { + synchronized(readBuf) { + readBuf.clear() + channel.receive(readBuf) ?: return@withContext null + readBuf.flip() + val out = ByteArray(readBuf.remaining()) + readBuf.get(out) + out + } + } catch (_: ClosedChannelException) { + null + } + } + + actual fun close() { + if (closed.compareAndSet(false, true)) { + try { + channel.close() + } catch (_: Throwable) { + // already closed + } + } + } + + actual companion object { + private const val MAX_DATAGRAM_SIZE: Int = 65535 + + actual suspend fun connect( + host: String, + port: Int, + ): UdpSocket = + withContext(Dispatchers.IO) { + val address = InetAddress.getByName(host) + val remote = InetSocketAddress(address, port) + val channel = DatagramChannel.open() + channel.configureBlocking(true) + channel.bind(InetSocketAddress(0)) // ephemeral + // We use receive()/send(addr) instead of channel.connect() so that + // sendDatagram-style flows can still be implemented on the same + // socket if we ever need them. For the pure client use-case this + // is identical in latency. + UdpSocket(channel, remote) + } + } +} diff --git a/settings.gradle b/settings.gradle index 192a1480d1..38a632ba1e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -36,6 +36,7 @@ include ':benchmark' include ':quartz' include ':commons' include ':ammolite' +include ':quic' include ':nestsClient' include ':desktopApp' include ':cli'