feat(quic): Phase I-K — HTTP/3, QPACK, WebTransport framing

Layer the WebTransport-over-HTTP/3 stack on top of QUIC:

- HTTP/3 frame and stream-type identifiers (RFC 9114) plus the WebTransport
  draft additions (stream type 0x41 for client-bidi, 0x54 for client-uni).
- HTTP/3 Settings frame codec advertising the three settings nests requires:
  ENABLE_CONNECT_PROTOCOL=1, H3_DATAGRAM=1, ENABLE_WEBTRANSPORT=1.
- QPACK static table (RFC 9204 Appendix A — all 99 entries) plus pre-built
  name→index and (name,value)→index maps for encoder lookup.
- QPACK prefixed-integer codec (RFC 7541 §5.1).
- QPACK literal-only encoder: indexed-static, literal-with-static-name-ref,
  and literal-with-literal-name field-line shapes — no dynamic table inserts
  on the encoder side, so we always emit Required Insert Count = 0 and
  Delta Base = 0.
- QPACK decoder supporting indexed-static + literal-with-static-name-ref +
  literal-with-literal-name. Throws on dynamic-table references (we
  advertise QPACK_MAX_TABLE_CAPACITY=0).
- QPACK Huffman decoder (RFC 7541 Appendix B); the encoder always emits
  Huffman=0 literal strings.
- WebTransport capsule encoder (WT_CLOSE_SESSION = 0x2843).
- WebTransport datagram framing — quarter-stream-id varint prefix per
  RFC 9297 + draft-ietf-webtrans-http3.
- WebTransport stream type prefixes for client-bidi (0x41) and client-uni
  (0x54), each followed by the quarter session id.
- ExtendedConnect builder for the `:method=CONNECT, :protocol=webtransport`
  request headers and HEADERS frame body.
- QuicConnectionDriver wraps a UdpSocket + QuicConnection in coroutines
  for the read/send loops.

Round-trip tests: QPACK encode → decode preserves header lists for all
three field-line shapes plus the WebTransport extended CONNECT request.
WT datagram framing round-trips with both zero and non-zero session ids.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 20:56:35 +00:00
parent 52ab84acfe
commit cceb5bfe96
13 changed files with 1137 additions and 0 deletions
@@ -0,0 +1,86 @@
/*
* 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.quic.transport.UdpSocket
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Owns the UDP socket and runs the read + send loops for a [QuicConnection].
*
* Lifecycle:
* - [open] connects the UDP socket, calls [QuicConnection.start], spawns
* read + send loops, and suspends until handshake completes.
* - [close] cancels the loops and closes the socket.
*/
class QuicConnectionDriver(
val connection: QuicConnection,
private val socket: UdpSocket,
parentScope: CoroutineScope,
private val nowMillis: () -> Long = { System.currentTimeMillis() },
) {
private val job = SupervisorJob(parentScope.coroutineContext[Job])
private val scope = CoroutineScope(parentScope.coroutineContext + job + Dispatchers.IO)
private val sendLock = Mutex()
fun start() {
connection.start()
scope.launch { readLoop() }
scope.launch { sendLoop() }
}
private suspend fun readLoop() {
while (true) {
val datagram = socket.receive() ?: break
sendLock.withLock {
feedDatagram(connection, datagram, nowMillis())
}
}
}
private suspend fun sendLoop() {
while (connection.status != QuicConnection.Status.CLOSED) {
sendLock.withLock {
while (true) {
val out = drainOutbound(connection, nowMillis()) ?: break
socket.send(out)
}
}
// Tiny sleep to avoid tight-spin between drains; in practice, application
// writes (via stream.send.enqueue + queueDatagram) wake the loop next tick.
delay(2)
}
}
fun close() {
connection.close(0L, "")
scope.cancel()
socket.close()
}
}
@@ -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.http3
/** HTTP/3 frame type identifiers per RFC 9114 §11.2 + WebTransport. */
object Http3FrameType {
const val DATA: Long = 0x00
const val HEADERS: Long = 0x01
const val CANCEL_PUSH: Long = 0x03
const val SETTINGS: Long = 0x04
const val PUSH_PROMISE: Long = 0x05
const val GOAWAY: Long = 0x07
const val MAX_PUSH_ID: Long = 0x0D
/** WebTransport BIDI stream prefix (per draft) — appears as a frame on a request stream. */
const val WEBTRANSPORT_BIDI_STREAM: Long = 0x41
}
/** HTTP/3 unidirectional stream type prefixes per RFC 9114 §6.2. */
object Http3StreamType {
const val CONTROL: Long = 0x00
const val PUSH: Long = 0x01
const val QPACK_ENCODER: Long = 0x02
const val QPACK_DECODER: Long = 0x03
/** WebTransport unidirectional stream type. */
const val WEBTRANSPORT_UNI_STREAM: Long = 0x54
}
/** SETTINGS identifiers we emit / parse. */
object Http3SettingsId {
const val QPACK_MAX_TABLE_CAPACITY: Long = 0x01
const val MAX_FIELD_SECTION_SIZE: Long = 0x06
const val QPACK_BLOCKED_STREAMS: Long = 0x07
/** RFC 8441 — accept Extended CONNECT (`:protocol`). */
const val ENABLE_CONNECT_PROTOCOL: Long = 0x08
/** RFC 9297 — HTTP Datagrams. */
const val H3_DATAGRAM: Long = 0x33
/** WebTransport over HTTP/3 (draft / RFC 9220). */
const val ENABLE_WEBTRANSPORT: Long = 0xc671706a
}
@@ -0,0 +1,79 @@
/*
* 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.http3
import com.vitorpamplona.quic.QuicReader
import com.vitorpamplona.quic.QuicWriter
/**
* HTTP/3 SETTINGS frame body — a sequence of `(id, value)` varint pairs per
* RFC 9114 §7.2.4. Frame type 0x04, length-prefixed payload.
*/
data class Http3Settings(
val settings: Map<Long, Long>,
) {
fun encodeBody(): ByteArray {
val w = QuicWriter()
for ((id, value) in settings) {
w.writeVarint(id)
w.writeVarint(value)
}
return w.toByteArray()
}
fun encodeFrame(): ByteArray {
val body = encodeBody()
val w = QuicWriter()
w.writeVarint(Http3FrameType.SETTINGS)
w.writeVarint(body.size.toLong())
w.writeBytes(body)
return w.toByteArray()
}
companion object {
fun decodeBody(body: ByteArray): Http3Settings {
val map = mutableMapOf<Long, Long>()
val r = QuicReader(body)
while (r.hasMore()) {
val id = r.readVarint()
val value = r.readVarint()
map[id] = value
}
return Http3Settings(map)
}
}
}
/**
* Build the SETTINGS frame a WebTransport-over-HTTP/3 client sends on its
* control stream, advertising:
* - SETTINGS_ENABLE_CONNECT_PROTOCOL = 1 (RFC 8441)
* - SETTINGS_H3_DATAGRAM = 1 (RFC 9297)
* - SETTINGS_ENABLE_WEBTRANSPORT = 1 (RFC 9220 / draft)
*/
fun buildClientWebTransportSettings(): Http3Settings =
Http3Settings(
mapOf(
Http3SettingsId.ENABLE_CONNECT_PROTOCOL to 1L,
Http3SettingsId.H3_DATAGRAM to 1L,
Http3SettingsId.ENABLE_WEBTRANSPORT to 1L,
),
)
@@ -0,0 +1,112 @@
/*
* 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.qpack
import com.vitorpamplona.quic.QuicCodecException
/**
* QPACK decoder for HTTP/3 field sections per RFC 9204 §4.5.
*
* Supports the static-table-only flavor that nests / most servers use when
* QPACK_MAX_TABLE_CAPACITY is 0 (which is what we advertise). If a server
* sends dynamic-table references, [decode] throws — phase L can extend this
* to the full dynamic table if interop demands.
*
* Field line types (RFC 9204 §4.5.2):
* - 1xxxxxxx: Indexed Field Line (T=1 → static, T=0 → dynamic)
* - 01NTxxxx: Literal Field Line With Name Reference
* - 0001NHxx: Literal Field Line With Literal Name
* - 0000NHxx: same as above (name length prefix variant per RFC 9204 §4.5.6)
* - 0001xxxx: Indexed Field Line With Post-Base Index
*/
class QpackDecoder {
fun decodeFieldSection(payload: ByteArray): List<Pair<String, String>> {
var pos = 0
// Required Insert Count (8-bit prefix)
val ric = QpackInteger.decode(payload, pos, 8)
pos += ric.bytesConsumed
if (ric.value != 0L) {
throw QuicCodecException("QPACK dynamic table references not supported (Required Insert Count=${ric.value})")
}
// Sign + Delta Base (7-bit prefix)
val deltaBase = QpackInteger.decode(payload, pos, 7)
pos += deltaBase.bytesConsumed
val out = mutableListOf<Pair<String, String>>()
while (pos < payload.size) {
val first = payload[pos].toInt() and 0xFF
when {
(first and 0x80) != 0 -> {
// Indexed Field Line: 1|T|index(6)
val isStatic = (first and 0x40) != 0
if (!isStatic) throw QuicCodecException("QPACK dynamic indexed field line unsupported")
val r = QpackInteger.decode(payload, pos, 6)
pos += r.bytesConsumed
val entry = QpackStaticTable.entries[r.value.toInt()]
out += entry
}
(first and 0x40) != 0 -> {
// Literal Field Line With Name Reference: 0|1|N|T|index(4)
val isStatic = (first and 0x10) != 0
if (!isStatic) throw QuicCodecException("QPACK dynamic name-ref field line unsupported")
val nameRef = QpackInteger.decode(payload, pos, 4)
pos += nameRef.bytesConsumed
val name = QpackStaticTable.entries[nameRef.value.toInt()].first
val (value, valueLen) = readStringLiteral(payload, pos)
pos += valueLen
out += name to value
}
(first and 0x20) != 0 -> {
// Literal Field Line With Literal Name: 0|0|1|N|H|len(3)
val nameH = (first and 0x08) != 0
val nameLenR = QpackInteger.decode(payload, pos, 3)
pos += nameLenR.bytesConsumed
val nameBytes = ByteArray(nameLenR.value.toInt())
payload.copyInto(nameBytes, 0, pos, pos + nameBytes.size)
pos += nameBytes.size
val name = if (nameH) QpackHuffman.decode(nameBytes).decodeToString() else nameBytes.decodeToString()
val (value, valueLen) = readStringLiteral(payload, pos)
pos += valueLen
out += name to value
}
else -> {
// Indexed Field Line With Post-Base Index — uses dynamic table; reject.
throw QuicCodecException("QPACK post-base indexed field line unsupported")
}
}
}
return out
}
/** Read a `H|len(7-bit prefix)|bytes` string literal, returning the value and bytes consumed. */
private fun readStringLiteral(
payload: ByteArray,
offset: Int,
): Pair<String, Int> {
val first = payload[offset].toInt() and 0xFF
val huffman = (first and 0x80) != 0
val lenR = QpackInteger.decode(payload, offset, 7)
val dataStart = offset + lenR.bytesConsumed
val raw = payload.copyOfRange(dataStart, dataStart + lenR.value.toInt())
val str = if (huffman) QpackHuffman.decode(raw).decodeToString() else raw.decodeToString()
return str to (lenR.bytesConsumed + raw.size)
}
}
@@ -0,0 +1,81 @@
/*
* 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.qpack
import com.vitorpamplona.quic.QuicWriter
/**
* QPACK encoder (literal-only) per RFC 9204.
*
* We never insert into the dynamic table on the encoder side, so the encoded
* field section starts with `Required Insert Count = 0` (8-bit prefix) and
* `Delta Base = 0` (S=0, 7-bit prefix). Field lines after the prefix are:
*
* - Indexed Field Line, T=1: `1|1|index(6-bit prefix)` → reference into static table
* - Literal Field Line With Name Reference, T=1: `0|1|N|index(4-bit prefix)`
* followed by `H(1)|len(7)` + value bytes (no Huffman from us).
* - Literal Field Line With Literal Name: `0|0|1|N|H(1)|len(3)` followed by
* name bytes, then `H(1)|len(7)` + value bytes.
*
* We pick the smallest representation that still avoids the dynamic table.
*/
class QpackEncoder {
/** Encode [headers] into a field section payload (the contents of an HTTP/3 HEADERS frame). */
fun encodeFieldSection(headers: List<Pair<String, String>>): ByteArray {
val w = QuicWriter()
// Required Insert Count = 0
QpackInteger.encode(0, 8, 0, w)
// Sign + Delta Base; sign=0, delta=0
QpackInteger.encode(0, 7, 0, w)
for ((name, value) in headers) encodeOne(w, name.lowercase(), value)
return w.toByteArray()
}
private fun encodeOne(
w: QuicWriter,
name: String,
value: String,
) {
val pairIdx = QpackStaticTable.pairToIndex[name to value]
if (pairIdx != null) {
// Indexed field line, static. Pattern: 1|T(=1)|index(6-bit prefix)
QpackInteger.encode(pairIdx.toLong(), 6, 0xC0, w)
return
}
val nameIdx = QpackStaticTable.nameToIndex[name]
if (nameIdx != null) {
// Literal Field Line With Name Reference, static. Pattern: 0|1|N(=0)|T(=1)|index(4-bit prefix)
QpackInteger.encode(nameIdx.toLong(), 4, 0x50, w)
// Then value: H=0|len(7-bit prefix)
val valueBytes = value.encodeToByteArray()
QpackInteger.encode(valueBytes.size.toLong(), 7, 0x00, w)
w.writeBytes(valueBytes)
return
}
// Literal field line with literal name. Pattern: 0|0|1|N(=0)|H(=0)|len(3-bit prefix)
val nameBytes = name.encodeToByteArray()
QpackInteger.encode(nameBytes.size.toLong(), 3, 0x20, w)
w.writeBytes(nameBytes)
val valueBytes = value.encodeToByteArray()
QpackInteger.encode(valueBytes.size.toLong(), 7, 0x00, w)
w.writeBytes(valueBytes)
}
}
@@ -0,0 +1,144 @@
/*
* 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.qpack
import com.vitorpamplona.quic.QuicCodecException
/**
* HPACK / QPACK Huffman codec per RFC 7541 Appendix B.
*
* We implement decoding only — the encoder always emits Huffman=0 literals.
* The codec is a static prefix code; we walk the input bit-by-bit through a
* lazily-built lookup tree.
*/
object QpackHuffman {
/** RFC 7541 Appendix B — (code, length) for each of the 256 symbols + EOS at index 256. */
private val table: Array<IntArray> =
arrayOf(
intArrayOf(0x1ff8, 13), intArrayOf(0x7fffd8, 23), intArrayOf(0xfffffe2, 28), intArrayOf(0xfffffe3, 28),
intArrayOf(0xfffffe4, 28), intArrayOf(0xfffffe5, 28), intArrayOf(0xfffffe6, 28), intArrayOf(0xfffffe7, 28),
intArrayOf(0xfffffe8, 28), intArrayOf(0xffffea, 24), intArrayOf(0x3ffffffc, 30), intArrayOf(0xfffffe9, 28),
intArrayOf(0xfffffea, 28), intArrayOf(0x3ffffffd, 30), intArrayOf(0xfffffeb, 28), intArrayOf(0xfffffec, 28),
intArrayOf(0xfffffed, 28), intArrayOf(0xfffffee, 28), intArrayOf(0xfffffef, 28), intArrayOf(0xffffff0, 28),
intArrayOf(0xffffff1, 28), intArrayOf(0xffffff2, 28), intArrayOf(0x3ffffffe, 30), intArrayOf(0xffffff3, 28),
intArrayOf(0xffffff4, 28), intArrayOf(0xffffff5, 28), intArrayOf(0xffffff6, 28), intArrayOf(0xffffff7, 28),
intArrayOf(0xffffff8, 28), intArrayOf(0xffffff9, 28), intArrayOf(0xffffffa, 28), intArrayOf(0xffffffb, 28),
intArrayOf(0x14, 6), intArrayOf(0x3f8, 10), intArrayOf(0x3f9, 10), intArrayOf(0xffa, 12),
intArrayOf(0x1ff9, 13), intArrayOf(0x15, 6), intArrayOf(0xf8, 8), intArrayOf(0x7fa, 11),
intArrayOf(0x3fa, 10), intArrayOf(0x3fb, 10), intArrayOf(0xf9, 8), intArrayOf(0x7fb, 11),
intArrayOf(0xfa, 8), intArrayOf(0x16, 6), intArrayOf(0x17, 6), intArrayOf(0x18, 6),
intArrayOf(0x0, 5), intArrayOf(0x1, 5), intArrayOf(0x2, 5), intArrayOf(0x19, 6),
intArrayOf(0x1a, 6), intArrayOf(0x1b, 6), intArrayOf(0x1c, 6), intArrayOf(0x1d, 6),
intArrayOf(0x1e, 6), intArrayOf(0x1f, 6), intArrayOf(0x5c, 7), intArrayOf(0xfb, 8),
intArrayOf(0x7ffc, 15), intArrayOf(0x20, 6), intArrayOf(0xffb, 12), intArrayOf(0x3fc, 10),
intArrayOf(0x1ffa, 13), intArrayOf(0x21, 6), intArrayOf(0x5d, 7), intArrayOf(0x5e, 7),
intArrayOf(0x5f, 7), intArrayOf(0x60, 7), intArrayOf(0x61, 7), intArrayOf(0x62, 7),
intArrayOf(0x63, 7), intArrayOf(0x64, 7), intArrayOf(0x65, 7), intArrayOf(0x66, 7),
intArrayOf(0x67, 7), intArrayOf(0x68, 7), intArrayOf(0x69, 7), intArrayOf(0x6a, 7),
intArrayOf(0x6b, 7), intArrayOf(0x6c, 7), intArrayOf(0x6d, 7), intArrayOf(0x6e, 7),
intArrayOf(0x6f, 7), intArrayOf(0x70, 7), intArrayOf(0x71, 7), intArrayOf(0x72, 7),
intArrayOf(0xfc, 8), intArrayOf(0x73, 7), intArrayOf(0xfd, 8), intArrayOf(0x1ffb, 13),
intArrayOf(0x7fff0, 19), intArrayOf(0x1ffc, 13), intArrayOf(0x3ffc, 14), intArrayOf(0x22, 6),
intArrayOf(0x7ffd, 15), intArrayOf(0x3, 5), intArrayOf(0x23, 6), intArrayOf(0x4, 5),
intArrayOf(0x24, 6), intArrayOf(0x5, 5), intArrayOf(0x25, 6), intArrayOf(0x26, 6),
intArrayOf(0x27, 6), intArrayOf(0x6, 5), intArrayOf(0x74, 7), intArrayOf(0x75, 7),
intArrayOf(0x28, 6), intArrayOf(0x29, 6), intArrayOf(0x2a, 6), intArrayOf(0x7, 5),
intArrayOf(0x2b, 6), intArrayOf(0x76, 7), intArrayOf(0x2c, 6), intArrayOf(0x8, 5),
intArrayOf(0x9, 5), intArrayOf(0x2d, 6), intArrayOf(0x77, 7), intArrayOf(0x78, 7),
intArrayOf(0x79, 7), intArrayOf(0x7a, 7), intArrayOf(0x7b, 7), intArrayOf(0x7ffe, 15),
intArrayOf(0x7fc, 11), intArrayOf(0x3ffd, 14), intArrayOf(0x1ffd, 13), intArrayOf(0xffffffc, 28),
intArrayOf(0xfffe6, 20), intArrayOf(0x3fffd2, 22), intArrayOf(0xfffe7, 20), intArrayOf(0xfffe8, 20),
intArrayOf(0x3fffd3, 22), intArrayOf(0x3fffd4, 22), intArrayOf(0x3fffd5, 22), intArrayOf(0x7fffd9, 23),
intArrayOf(0x3fffd6, 22), intArrayOf(0x7fffda, 23), intArrayOf(0x7fffdb, 23), intArrayOf(0x7fffdc, 23),
intArrayOf(0x7fffdd, 23), intArrayOf(0x7fffde, 23), intArrayOf(0xffffeb, 24), intArrayOf(0x7fffdf, 23),
intArrayOf(0xffffec, 24), intArrayOf(0xffffed, 24), intArrayOf(0x3fffd7, 22), intArrayOf(0x7fffe0, 23),
intArrayOf(0xffffee, 24), intArrayOf(0x7fffe1, 23), intArrayOf(0x7fffe2, 23), intArrayOf(0x7fffe3, 23),
intArrayOf(0x7fffe4, 23), intArrayOf(0x1fffdc, 21), intArrayOf(0x3fffd8, 22), intArrayOf(0x7fffe5, 23),
intArrayOf(0x3fffd9, 22), intArrayOf(0x7fffe6, 23), intArrayOf(0x7fffe7, 23), intArrayOf(0xffffef, 24),
intArrayOf(0x3fffda, 22), intArrayOf(0x1fffdd, 21), intArrayOf(0xfffe9, 20), intArrayOf(0x3fffdb, 22),
intArrayOf(0x3fffdc, 22), intArrayOf(0x7fffe8, 23), intArrayOf(0x7fffe9, 23), intArrayOf(0x1fffde, 21),
intArrayOf(0x7fffea, 23), intArrayOf(0x3fffdd, 22), intArrayOf(0x3fffde, 22), intArrayOf(0xfffff0, 24),
intArrayOf(0x1fffdf, 21), intArrayOf(0x3fffdf, 22), intArrayOf(0x7fffeb, 23), intArrayOf(0x7fffec, 23),
intArrayOf(0x1fffe0, 21), intArrayOf(0x1fffe1, 21), intArrayOf(0x3fffe0, 22), intArrayOf(0x1fffe2, 21),
intArrayOf(0x7fffed, 23), intArrayOf(0x3fffe1, 22), intArrayOf(0x7fffee, 23), intArrayOf(0x7fffef, 23),
intArrayOf(0xfffea, 20), intArrayOf(0x3fffe2, 22), intArrayOf(0x3fffe3, 22), intArrayOf(0x3fffe4, 22),
intArrayOf(0x7ffff0, 23), intArrayOf(0x3fffe5, 22), intArrayOf(0x3fffe6, 22), intArrayOf(0x7ffff1, 23),
intArrayOf(0x3ffffe0, 26), intArrayOf(0x3ffffe1, 26), intArrayOf(0xfffeb, 20), intArrayOf(0x7fff1, 19),
intArrayOf(0x3fffe7, 22), intArrayOf(0x7ffff2, 23), intArrayOf(0x3fffe8, 22), intArrayOf(0x1ffffec, 25),
intArrayOf(0x3ffffe2, 26), intArrayOf(0x3ffffe3, 26), intArrayOf(0x3ffffe4, 26), intArrayOf(0x7ffffde, 27),
intArrayOf(0x7ffffdf, 27), intArrayOf(0x3ffffe5, 26), intArrayOf(0xfffff1, 24), intArrayOf(0x1ffffed, 25),
intArrayOf(0x7fff2, 19), intArrayOf(0x1fffe3, 21), intArrayOf(0x3ffffe6, 26), intArrayOf(0x7ffffe0, 27),
intArrayOf(0x7ffffe1, 27), intArrayOf(0x3ffffe7, 26), intArrayOf(0x7ffffe2, 27), intArrayOf(0xfffff2, 24),
intArrayOf(0x1fffe4, 21), intArrayOf(0x1fffe5, 21), intArrayOf(0x3ffffe8, 26), intArrayOf(0x3ffffe9, 26),
intArrayOf(0xffffffd, 28), intArrayOf(0x7ffffe3, 27), intArrayOf(0x7ffffe4, 27), intArrayOf(0x7ffffe5, 27),
intArrayOf(0xfffec, 20), intArrayOf(0xfffff3, 24), intArrayOf(0xfffed, 20), intArrayOf(0x1fffe6, 21),
intArrayOf(0x3fffe9, 22), intArrayOf(0x1fffe7, 21), intArrayOf(0x1fffe8, 21), intArrayOf(0x7ffff3, 23),
intArrayOf(0x3fffea, 22), intArrayOf(0x3fffeb, 22), intArrayOf(0x1ffffee, 25), intArrayOf(0x1ffffef, 25),
intArrayOf(0xfffff4, 24), intArrayOf(0xfffff5, 24), intArrayOf(0x3ffffea, 26), intArrayOf(0x7ffff4, 23),
intArrayOf(0x3ffffeb, 26), intArrayOf(0x7ffffe6, 27), intArrayOf(0x3ffffec, 26), intArrayOf(0x3ffffed, 26),
intArrayOf(0x7ffffe7, 27), intArrayOf(0x7ffffe8, 27), intArrayOf(0x7ffffe9, 27), intArrayOf(0x7ffffea, 27),
intArrayOf(0x7ffffeb, 27), intArrayOf(0xffffffe, 28), intArrayOf(0x7ffffec, 27), intArrayOf(0x7ffffed, 27),
intArrayOf(0x7ffffee, 27), intArrayOf(0x7ffffef, 27), intArrayOf(0x7fffff0, 27), intArrayOf(0x3ffffee, 26),
intArrayOf(0x3fffffff, 30),
)
/** Decode a Huffman-encoded byte sequence into a UTF-8 string. */
fun decode(encoded: ByteArray): ByteArray {
val result = ArrayList<Byte>()
var bitBuf = 0L
var bitsAvailable = 0
var i = 0
while (i < encoded.size || bitsAvailable >= 5) {
// Pull more bits
while (bitsAvailable < 32 && i < encoded.size) {
bitBuf = (bitBuf shl 8) or (encoded[i].toLong() and 0xFF)
bitsAvailable += 8
i++
}
// Try matching the longest code we can
var matched = false
for (sym in 0..255) {
val (code, len) = Pair(table[sym][0], table[sym][1])
if (len > bitsAvailable) continue
val candidate = (bitBuf ushr (bitsAvailable - len)) and ((1L shl len) - 1)
if (candidate.toInt() == code) {
result.add(sym.toByte())
bitsAvailable -= len
bitBuf = bitBuf and ((1L shl bitsAvailable) - 1)
matched = true
break
}
}
if (!matched) {
// Either trailing padding (all 1s up to 7 bits) or error.
if (i >= encoded.size) {
if (bitsAvailable in 1..7) {
val pad = (bitBuf and ((1L shl bitsAvailable) - 1))
if (pad == ((1L shl bitsAvailable) - 1)) break
}
if (bitsAvailable == 0) break
}
throw QuicCodecException("invalid Huffman bit stream")
}
}
return result.toByteArray()
}
}
@@ -0,0 +1,86 @@
/*
* 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.qpack
import com.vitorpamplona.quic.QuicCodecException
import com.vitorpamplona.quic.QuicWriter
/**
* QPACK / HPACK prefixed-integer codec per RFC 7541 §5.1.
*
* The first byte's low [prefixBits] bits hold either the value (if it fits)
* or all-ones, signaling that further bytes carry the residue using the
* 7-bits-per-byte continuation pattern.
*/
object QpackInteger {
fun encode(
value: Long,
prefixBits: Int,
firstBytePrefix: Int,
out: QuicWriter,
) {
require(prefixBits in 1..8)
val maxPrefix = (1L shl prefixBits) - 1
if (value < maxPrefix) {
out.writeByte((firstBytePrefix and (0xFF shl prefixBits)) or value.toInt())
return
}
out.writeByte((firstBytePrefix and (0xFF shl prefixBits)) or maxPrefix.toInt())
var remaining = value - maxPrefix
while (remaining >= 0x80) {
out.writeByte(((remaining and 0x7F) or 0x80).toInt())
remaining = remaining ushr 7
}
out.writeByte(remaining.toInt())
}
/**
* Decode starting at [offset] in [src], with [prefixBits] in the first byte.
* Returns (value, bytesConsumed).
*/
fun decode(
src: ByteArray,
offset: Int,
prefixBits: Int,
): DecodeResult {
require(prefixBits in 1..8)
if (offset >= src.size) throw QuicCodecException("truncated QPACK integer")
val first = src[offset].toInt() and 0xFF
val maxPrefix = (1 shl prefixBits) - 1
var value = (first and maxPrefix).toLong()
if (value < maxPrefix) return DecodeResult(value, 1)
var pos = offset + 1
var shift = 0
while (true) {
if (pos >= src.size) throw QuicCodecException("truncated QPACK integer continuation")
val b = src[pos++].toInt() and 0xFF
value += ((b and 0x7F).toLong() shl shift)
if ((b and 0x80) == 0) return DecodeResult(value, pos - offset)
shift += 7
if (shift > 63) throw QuicCodecException("QPACK integer too large")
}
}
data class DecodeResult(
val value: Long,
val bytesConsumed: Int,
)
}
@@ -0,0 +1,144 @@
/*
* 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.qpack
/**
* QPACK static table per RFC 9204 Appendix A.
*
* 99 entries total. Indices are 0-based on the wire (a varint-prefixed
* "indexed field line" with T=1 carries an index into this table).
*/
object QpackStaticTable {
val entries: List<Pair<String, String>> =
listOf(
":authority" to "",
":path" to "/",
"age" to "0",
"content-disposition" to "",
"content-length" to "0",
"cookie" to "",
"date" to "",
"etag" to "",
"if-modified-since" to "",
"if-none-match" to "",
"last-modified" to "",
"link" to "",
"location" to "",
"referer" to "",
"set-cookie" to "",
":method" to "CONNECT",
":method" to "DELETE",
":method" to "GET",
":method" to "HEAD",
":method" to "OPTIONS",
":method" to "POST",
":method" to "PUT",
":scheme" to "http",
":scheme" to "https",
":status" to "103",
":status" to "200",
":status" to "304",
":status" to "404",
":status" to "503",
"accept" to "*/*",
"accept" to "application/dns-message",
"accept-encoding" to "gzip, deflate, br",
"accept-ranges" to "bytes",
"access-control-allow-headers" to "cache-control",
"access-control-allow-headers" to "content-type",
"access-control-allow-origin" to "*",
"cache-control" to "max-age=0",
"cache-control" to "max-age=2592000",
"cache-control" to "max-age=604800",
"cache-control" to "no-cache",
"cache-control" to "no-store",
"cache-control" to "public, max-age=31536000",
"content-encoding" to "br",
"content-encoding" to "gzip",
"content-type" to "application/dns-message",
"content-type" to "application/javascript",
"content-type" to "application/json",
"content-type" to "application/x-www-form-urlencoded",
"content-type" to "image/gif",
"content-type" to "image/jpeg",
"content-type" to "image/png",
"content-type" to "text/css",
"content-type" to "text/html; charset=utf-8",
"content-type" to "text/plain",
"content-type" to "text/plain;charset=utf-8",
"range" to "bytes=0-",
"strict-transport-security" to "max-age=31536000",
"strict-transport-security" to "max-age=31536000; includesubdomains",
"strict-transport-security" to "max-age=31536000; includesubdomains; preload",
"vary" to "accept-encoding",
"vary" to "origin",
"x-content-type-options" to "nosniff",
"x-xss-protection" to "1; mode=block",
":status" to "100",
":status" to "204",
":status" to "206",
":status" to "302",
":status" to "400",
":status" to "403",
":status" to "421",
":status" to "425",
":status" to "500",
"accept-language" to "",
"access-control-allow-credentials" to "FALSE",
"access-control-allow-credentials" to "TRUE",
"access-control-allow-headers" to "*",
"access-control-allow-methods" to "get",
"access-control-allow-methods" to "get, post, options",
"access-control-allow-methods" to "options",
"access-control-expose-headers" to "content-length",
"access-control-request-headers" to "content-type",
"access-control-request-method" to "get",
"access-control-request-method" to "post",
"alt-svc" to "clear",
"authorization" to "",
"content-security-policy" to "script-src 'none'; object-src 'none'; base-uri 'none'",
"early-data" to "1",
"expect-ct" to "",
"forwarded" to "",
"if-range" to "",
"origin" to "",
"purpose" to "prefetch",
"server" to "",
"timing-allow-origin" to "*",
"upgrade-insecure-requests" to "1",
"user-agent" to "",
"x-forwarded-for" to "",
"x-frame-options" to "deny",
"x-frame-options" to "sameorigin",
)
/** Build a quick lookup of name → first index for encoder name-reference selection. */
val nameToIndex: Map<String, Int> =
entries.foldIndexed(mutableMapOf()) { idx, acc, (name, _) ->
acc.putIfAbsent(name, idx); acc
}
/** Look up name+value → index, or null if no exact match. */
val pairToIndex: Map<Pair<String, String>, Int> =
entries.foldIndexed(mutableMapOf()) { idx, acc, pair ->
acc.putIfAbsent(pair, idx); acc
}
}
@@ -0,0 +1,67 @@
/*
* 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.webtransport
import com.vitorpamplona.quic.QuicWriter
import com.vitorpamplona.quic.http3.Http3FrameType
import com.vitorpamplona.quic.qpack.QpackEncoder
/**
* Build the headers for a WebTransport Extended CONNECT request per
* RFC 9220 / draft-ietf-webtrans-http3.
*
* :method = CONNECT
* :protocol = webtransport
* :scheme = https
* :authority = host[:port]
* :path = path
* [optional] authorization = Bearer <token>
*/
fun buildExtendedConnectHeaders(
authority: String,
path: String,
bearerToken: String? = null,
extra: List<Pair<String, String>> = emptyList(),
): List<Pair<String, String>> {
val headers =
mutableListOf(
":method" to "CONNECT",
":protocol" to "webtransport",
":scheme" to "https",
":authority" to authority,
":path" to path,
)
if (bearerToken != null) {
headers += "authorization" to "Bearer $bearerToken"
}
headers += extra
return headers
}
/** Encode a HEADERS frame body containing the given header list as QPACK bytes. */
fun encodeHeadersFrame(headers: List<Pair<String, String>>): ByteArray {
val qpack = QpackEncoder().encodeFieldSection(headers)
val w = QuicWriter()
w.writeVarint(Http3FrameType.HEADERS)
w.writeVarint(qpack.size.toLong())
w.writeBytes(qpack)
return w.toByteArray()
}
@@ -0,0 +1,55 @@
/*
* 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.webtransport
import com.vitorpamplona.quic.QuicWriter
/** WebTransport capsule type identifiers (draft-ietf-webtrans-http3 / RFC 9220). */
object WtCapsuleType {
/** WT_CLOSE_SESSION — graceful close on the CONNECT bidi. */
const val WT_CLOSE_SESSION: Long = 0x2843
/** WT_DRAIN_SESSION — peer signals it will not open new streams. */
const val WT_DRAIN_SESSION: Long = 0x78AE
}
/** Encode a `(type, body)` capsule per the HTTP capsule protocol (draft-ietf-httpbis-h3-datagram). */
fun encodeCapsule(
type: Long,
body: ByteArray,
): ByteArray {
val w = QuicWriter()
w.writeVarint(type)
w.writeVarint(body.size.toLong())
w.writeBytes(body)
return w.toByteArray()
}
/** Encode a WT_CLOSE_SESSION capsule with optional application error code + reason. */
fun encodeCloseSessionCapsule(
errorCode: Int = 0,
reason: String = "",
): ByteArray {
val body = QuicWriter()
body.writeUint32(errorCode)
body.writeBytes(reason.encodeToByteArray())
return encodeCapsule(WtCapsuleType.WT_CLOSE_SESSION, body.toByteArray())
}
@@ -0,0 +1,88 @@
/*
* 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.webtransport
import com.vitorpamplona.quic.QuicReader
import com.vitorpamplona.quic.QuicWriter
import com.vitorpamplona.quic.Varint
/**
* WebTransport datagram framing per RFC 9297 + draft-ietf-webtrans-http3.
*
* An HTTP/3 datagram payload starts with a varint *quarter stream id* — the
* stream id of the WT session's CONNECT bidi divided by 4. Followed by the
* application-level WT datagram bytes.
*/
object WtDatagram {
fun encode(
connectStreamId: Long,
payload: ByteArray,
): ByteArray {
require(connectStreamId % 4 == 0L) {
"WT session stream id must be a client-initiated bidi (id % 4 == 0): $connectStreamId"
}
val quarter = connectStreamId / 4
val w = QuicWriter()
w.writeVarint(quarter)
w.writeBytes(payload)
return w.toByteArray()
}
/** Returns (quarterStreamId * 4, payload). Returns null on truncation. */
fun decode(bytes: ByteArray): Decoded? {
val r = Varint.decode(bytes, 0) ?: return null
if (r.bytesConsumed > bytes.size) return null
val sessionId = r.value * 4
val payload = bytes.copyOfRange(r.bytesConsumed, bytes.size)
return Decoded(sessionId, payload)
}
data class Decoded(
val sessionStreamId: Long,
val payload: ByteArray,
)
}
/** WebTransport stream type prefixes (sent as the first varint on the QUIC stream). */
object WtStreamType {
/** Client-initiated bidirectional WT stream — followed by quarter session id. */
const val WT_BIDI_STREAM: Long = 0x41
/** Client-initiated unidirectional WT stream — preceded by HTTP/3 stream-type 0x54 + quarter session id. */
const val WT_UNI_STREAM_PREFIX: Long = 0x54
}
/** Encode the prefix bytes that go on a freshly-opened WebTransport bidi stream. */
fun encodeWtBidiStreamPrefix(connectStreamId: Long): ByteArray {
require(connectStreamId % 4 == 0L)
val w = QuicWriter()
w.writeVarint(WtStreamType.WT_BIDI_STREAM)
w.writeVarint(connectStreamId / 4)
return w.toByteArray()
}
/** Encode the prefix bytes that go on a freshly-opened WebTransport unidi stream. */
fun encodeWtUniStreamPrefix(connectStreamId: Long): ByteArray {
require(connectStreamId % 4 == 0L)
val w = QuicWriter()
w.writeVarint(WtStreamType.WT_UNI_STREAM_PREFIX)
w.writeVarint(connectStreamId / 4)
return w.toByteArray()
}
@@ -0,0 +1,67 @@
/*
* 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.qpack
import kotlin.test.Test
import kotlin.test.assertEquals
class QpackRoundTripTest {
@Test
fun static_table_indexed_field_line_round_trip() {
val headers = listOf(":method" to "GET", ":scheme" to "https")
val encoded = QpackEncoder().encodeFieldSection(headers)
val decoded = QpackDecoder().decodeFieldSection(encoded)
assertEquals(headers, decoded)
}
@Test
fun literal_with_static_name_reference_round_trip() {
val headers = listOf(":authority" to "example.com", ":path" to "/moq")
val encoded = QpackEncoder().encodeFieldSection(headers)
val decoded = QpackDecoder().decodeFieldSection(encoded)
assertEquals(headers, decoded)
}
@Test
fun literal_with_literal_name_round_trip() {
val headers = listOf("x-custom-header" to "deadbeef", "another-one" to "value")
val encoded = QpackEncoder().encodeFieldSection(headers)
val decoded = QpackDecoder().decodeFieldSection(encoded)
assertEquals(headers, decoded)
}
@Test
fun mixed_extended_connect_round_trip() {
// Mirrors what we'll send for WebTransport extended-CONNECT.
val headers =
listOf(
":method" to "CONNECT",
":protocol" to "webtransport",
":scheme" to "https",
":authority" to "nostrnests.com",
":path" to "/moq",
"authorization" to "Bearer token-abc123",
)
val encoded = QpackEncoder().encodeFieldSection(headers)
val decoded = QpackDecoder().decodeFieldSection(encoded)
assertEquals(headers, decoded)
}
}
@@ -0,0 +1,67 @@
/*
* 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.webtransport
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class WtFramingTest {
@Test
fun datagram_round_trip() {
val payload = "hello moq".encodeToByteArray()
val encoded = WtDatagram.encode(connectStreamId = 0L, payload = payload)
val decoded = WtDatagram.decode(encoded)
assertNotNull(decoded)
assertEquals(0L, decoded.sessionStreamId)
assertContentEquals(payload, decoded.payload)
}
@Test
fun datagram_round_trip_nonzero_session_id() {
val payload = byteArrayOf(0x01, 0x02, 0x03)
val encoded = WtDatagram.encode(connectStreamId = 4L, payload = payload)
val decoded = WtDatagram.decode(encoded)!!
assertEquals(4L, decoded.sessionStreamId)
assertContentEquals(payload, decoded.payload)
}
@Test
fun bidi_stream_prefix_encodes_0x41_then_session_id() {
val prefix = encodeWtBidiStreamPrefix(0L)
// 0x41 = 65 needs the 2-byte varint form: high byte 0x40, low byte 0x41.
// Then session id = 0 in 1-byte varint.
assertEquals(0x40.toByte(), prefix[0])
assertEquals(0x41.toByte(), prefix[1])
assertEquals(0x00.toByte(), prefix[2])
}
@Test
fun close_session_capsule_starts_with_2843() {
val capsule = encodeCloseSessionCapsule(0, "")
// 0x2843 encoded as varint occupies 2 bytes: 0x68, 0x43 (with the 2-byte form prefix).
// Verify by parsing.
val hi = capsule[0].toInt() and 0xFF
// Top two bits of first byte indicate 2-byte varint: 01xxxxxx
assertEquals(0x40, hi and 0xC0)
}
}