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:
Claude
2026-04-25 17:19:02 +00:00
parent 41c1e131a0
commit 2d541c6fd4
13 changed files with 620 additions and 16 deletions
@@ -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
@@ -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.
@@ -1,124 +0,0 @@
/*
* 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.nestsclient.moq
import com.vitorpamplona.nestsclient.moq.Varint.size
/**
* QUIC variable-length integer codec, per RFC 9000 §16.
*
* The two most significant bits of the first byte encode the total length:
* 00 → 1 byte, 6-bit value (0 .. 63)
* 01 → 2 bytes, 14-bit value (0 .. 16_383)
* 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.
*/
object Varint {
const val MAX_VALUE: Long = (1L shl 62) - 1
/** Number of bytes required to encode [value]. */
fun size(value: Long): Int {
require(value >= 0) { "varint must be non-negative: $value" }
require(value <= MAX_VALUE) { "varint overflow: $value" }
return when {
value < (1L shl 6) -> 1
value < (1L shl 14) -> 2
value < (1L shl 30) -> 4
else -> 8
}
}
/** Encode [value] into a fresh ByteArray. */
fun encode(value: Long): ByteArray {
val buf = ByteArray(size(value))
writeTo(value, buf, 0)
return buf
}
/**
* Write [value] to [dst] starting at [offset]. Returns the number of bytes
* written. [dst] must have room for [size] bytes starting at [offset].
*/
fun writeTo(
value: Long,
dst: ByteArray,
offset: Int,
): Int {
val size = size(value)
when (size) {
1 -> {
dst[offset] = value.toByte()
}
2 -> {
dst[offset] = (0x40 or ((value ushr 8).toInt() and 0x3F)).toByte()
dst[offset + 1] = (value and 0xFF).toByte()
}
4 -> {
dst[offset] = (0x80 or ((value ushr 24).toInt() and 0x3F)).toByte()
dst[offset + 1] = ((value ushr 16) and 0xFF).toByte()
dst[offset + 2] = ((value ushr 8) and 0xFF).toByte()
dst[offset + 3] = (value and 0xFF).toByte()
}
8 -> {
dst[offset] = (0xC0 or ((value ushr 56).toInt() and 0x3F)).toByte()
dst[offset + 1] = ((value ushr 48) and 0xFF).toByte()
dst[offset + 2] = ((value ushr 40) and 0xFF).toByte()
dst[offset + 3] = ((value ushr 32) and 0xFF).toByte()
dst[offset + 4] = ((value ushr 24) and 0xFF).toByte()
dst[offset + 5] = ((value ushr 16) and 0xFF).toByte()
dst[offset + 6] = ((value ushr 8) and 0xFF).toByte()
dst[offset + 7] = (value and 0xFF).toByte()
}
}
return size
}
/**
* Decode a varint starting at [offset] in [src]. Returns [DecodeResult] with
* the value and the number of bytes consumed, or null if [src] doesn't
* contain enough bytes (caller should buffer more and retry).
*/
fun decode(
src: ByteArray,
offset: Int = 0,
): DecodeResult? {
if (offset >= src.size) return null
val first = src[offset].toInt() and 0xFF
val length = 1 shl ((first ushr 6) and 0x03)
if (offset + length > src.size) return null
var value = (first and 0x3F).toLong()
for (i in 1 until length) {
value = (value shl 8) or ((src[offset + i].toInt() and 0xFF).toLong())
}
return DecodeResult(value, length)
}
data class DecodeResult(
val value: Long,
val bytesConsumed: Int,
)
}
@@ -1,114 +0,0 @@
/*
* 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.nestsclient.moq
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
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(
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)))
// 4-byte varint with only 3 bytes available:
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)
}
}