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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user