feat(quic): Phase A — module foundations
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
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class VarintTest {
|
||||
@Test
|
||||
fun rfc9000_sample_151288809941952652() {
|
||||
val value = 151288809941952652L
|
||||
val encoded = Varint.encode(value)
|
||||
assertContentEquals(
|
||||
byteArrayOf(0xC2.toByte(), 0x19, 0x7C, 0x5E, 0xFF.toByte(), 0x14, 0xE8.toByte(), 0x8C.toByte()),
|
||||
encoded,
|
||||
)
|
||||
assertEquals(value, Varint.decode(encoded)!!.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rfc9000_sample_494878333() {
|
||||
val value = 494878333L
|
||||
val encoded = Varint.encode(value)
|
||||
assertContentEquals(
|
||||
byteArrayOf(0x9D.toByte(), 0x7F.toByte(), 0x3E, 0x7D.toByte()),
|
||||
encoded,
|
||||
)
|
||||
assertEquals(value, Varint.decode(encoded)!!.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rfc9000_sample_15293() {
|
||||
val value = 15293L
|
||||
val encoded = Varint.encode(value)
|
||||
assertContentEquals(byteArrayOf(0x7B.toByte(), 0xBD.toByte()), encoded)
|
||||
assertEquals(value, Varint.decode(encoded)!!.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rfc9000_sample_37() {
|
||||
val encoded = Varint.encode(37L)
|
||||
assertContentEquals(byteArrayOf(0x25), encoded)
|
||||
assertEquals(37L, Varint.decode(encoded)!!.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun size_matches_encoding_length() {
|
||||
for (v in listOf(0L, 63L, 64L, 16_383L, 16_384L, 1_073_741_823L, 1_073_741_824L, Varint.MAX_VALUE)) {
|
||||
assertEquals(Varint.size(v), Varint.encode(v).size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negative_value_is_rejected() {
|
||||
assertFailsWith<IllegalArgumentException> { Varint.encode(-1L) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun overflow_value_is_rejected() {
|
||||
assertFailsWith<IllegalArgumentException> { Varint.encode(Varint.MAX_VALUE + 1) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun short_buffer_returns_null_so_caller_can_buffer_more() {
|
||||
assertNull(Varint.decode(ByteArray(0)))
|
||||
assertNull(Varint.decode(byteArrayOf(0x9D.toByte(), 0x7F.toByte(), 0x3E), 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bytesConsumed_reflects_actual_length() {
|
||||
assertEquals(1, Varint.decode(byteArrayOf(0x25))!!.bytesConsumed)
|
||||
assertEquals(2, Varint.decode(byteArrayOf(0x7B.toByte(), 0xBD.toByte()))!!.bytesConsumed)
|
||||
assertEquals(4, Varint.decode(byteArrayOf(0x9D.toByte(), 0x7F.toByte(), 0x3E, 0x7D.toByte()))!!.bytesConsumed)
|
||||
}
|
||||
}
|
||||
@@ -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<IllegalArgumentException> { 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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user