fix(quic): audit-3 follow-ups + regression coverage

Cipher caching: cache JCA Cipher + SecretKeySpec per direction in a new
JcaAesGcmAead so steady-state seal/open avoids Cipher.getInstance("AES/GCM/
NoPadding") per packet (audit-1, audit-3 hot path). Initial-padding rebuild
edge case (re-encrypting the same PN with the same nonce) falls back to a
fresh cipher because JCA tracks (key, iv) pairs and rejects legitimate reuse.

Channel-based wakeups: replace the WT peer-stream poller's delay(5)
busy-loop with awaitIncomingPeerStream/awaitIncomingDatagram suspending
on conflated wakeup channels fired by the parser. Connection close also
closes a closedSignal so any awaiter unblocks promptly with null.

Driver close ordering: close() now joins the read + send loops with a
bounded timeout instead of yield()+cancel()-racing them. Catches the case
where scope.cancel() fired mid-socket.send, occasionally producing partial
datagrams or skipping CONNECTION_CLOSE entirely.

WT graceful close: spawn a CapsuleReader-driven coroutine on the CONNECT
bidi that decodes WT_CLOSE_SESSION and surfaces it via peerCloseSession +
awaitPeerClose. Previously the encoder existed but no decoder consumed
incoming capsules, so peer-initiated graceful close was silent.

GOAWAY: WtPeerStreamDemux decodes the GOAWAY varint body into
peerGoawayStreamId instead of `is Goaway -> Unit`-dropping it.

TLS transcript hash: incremental SHA-256 backed by JCA MessageDigest,
snapshotted via clone() — replaces the O(n²) "concatenate-everything-and-
re-hash on every snapshot" implementation. TLS 1.3 takes ≥3 snapshots per
handshake.

MAX_STREAMS routing: parser now bumps peerMaxStreamsBidi/Uni on inbound
MAX_STREAMS frames; openBidiStream/openUniStream throw QuicStreamLimitException
when the cap is reached instead of silently overrunning it. Initial cap
sourced from peer transport parameters.

Regression tests:
  * CapsuleReaderTest – round-trip, split-chunk, partial, unknown types
  * TlsTranscriptHashTest – snapshot determinism, no consume-on-snapshot
  * PeerStreamLimitTest – TP-driven cap, MAX_STREAMS frame round-trip
  * WtPeerStreamDemuxTest – CONTROL stream GOAWAY decode

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 23:48:44 +00:00
parent 2f9a0a0e03
commit 62a42cb36d
20 changed files with 1345 additions and 86 deletions
@@ -58,8 +58,13 @@ import com.vitorpamplona.quic.tls.TlsConstants
class InMemoryQuicPipe(
val client: QuicConnection,
val initialDcid: ByteArray,
/**
* Optional pre-configured TLS server. Tests that need to advertise non-empty
* QUIC transport parameters (e.g. to exercise MAX_STREAMS routing) build
* their own [InProcessTlsServer] and pass it here.
*/
private val tlsServer: InProcessTlsServer = InProcessTlsServer(),
) {
private val tlsServer = InProcessTlsServer()
private val initial = InitialSecrets.derive(initialDcid)
private val hp = AesEcbHeaderProtection(PlatformAesOneBlock)
@@ -0,0 +1,126 @@
/*
* 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.frame.MaxStreamsFrame
import com.vitorpamplona.quic.tls.InProcessTlsServer
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* Verifies the audit-3 fix: peer-granted stream concurrency limits are now
* tracked and enforced.
*
* Two paths feed the cap:
* 1. Peer transport parameters at handshake (`initial_max_streams_bidi/uni`)
* 2. Subsequent MAX_STREAMS frames (RFC 9000 §19.11)
*
* Without this enforcement, [QuicConnection.openBidiStream] silently allocated
* stream IDs past the cap, and the peer eventually closed the connection with
* STREAM_LIMIT_ERROR — a failure that surfaced as "connection randomly drops
* after a burst of opens" rather than a clean error.
*/
class PeerStreamLimitTest {
@Test
fun open_bidi_throws_when_peer_advertises_zero_bidi_streams() {
runBlocking {
val client =
QuicConnection(
serverName = "example.test",
config = QuicConnectionConfig(),
tlsCertificateValidator = null,
)
// Default InProcessTlsServer sends empty transport parameters — so
// the client's peerMaxStreamsBidi resolves to 0 after handshake.
val pipe = InMemoryQuicPipe(client, client.destinationConnectionId.bytes)
client.start()
pipe.drive(maxRounds = 16)
assertEquals(QuicConnection.Status.CONNECTED, client.status)
assertFailsWith<QuicStreamLimitException> {
client.openBidiStream()
}
}
}
@Test
fun open_bidi_succeeds_within_peer_advertised_cap_and_throws_at_boundary() {
runBlocking {
val client =
QuicConnection(
serverName = "example.test",
config = QuicConnectionConfig(),
tlsCertificateValidator = null,
)
val serverTpBytes =
TransportParameters(
initialMaxData = 1_000_000,
initialMaxStreamDataBidiLocal = 100_000,
initialMaxStreamDataBidiRemote = 100_000,
initialMaxStreamDataUni = 100_000,
initialMaxStreamsBidi = 3,
initialMaxStreamsUni = 0,
).encode()
val tlsServer = InProcessTlsServer(transportParameters = serverTpBytes)
val pipe =
InMemoryQuicPipe(
client = client,
initialDcid = client.destinationConnectionId.bytes,
tlsServer = tlsServer,
)
client.start()
pipe.drive(maxRounds = 16)
assertEquals(QuicConnection.Status.CONNECTED, client.status)
assertEquals(3L, client.peerMaxStreamsBidiSnapshot())
// Three opens should succeed.
client.openBidiStream()
client.openBidiStream()
client.openBidiStream()
// Fourth must throw — we'd otherwise violate the peer's cap.
assertFailsWith<QuicStreamLimitException> {
client.openBidiStream()
}
}
}
@Test
fun max_streams_frame_roundtrips_via_decode_frames() {
// Bytes-level sanity: encode a MaxStreamsFrame and decode it back.
// Catches a regression where the parser ignored MAX_STREAMS entirely
// (the pre-fix `is MaxStreamsFrame -> { /* tracking left for later */ }`
// branch was dead code as far as tests could observe).
val encoded =
com.vitorpamplona.quic.frame
.encodeFrames(listOf(MaxStreamsFrame(bidi = true, maxStreams = 100)))
val decoded =
com.vitorpamplona.quic.frame
.decodeFrames(encoded)
assertEquals(1, decoded.size)
val frame = decoded.first() as MaxStreamsFrame
assertTrue(frame.bidi)
assertEquals(100L, frame.maxStreams)
}
}
@@ -0,0 +1,134 @@
/*
* 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.packet
import kotlin.test.Test
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Regression coverage for hostile-input handling at the packet layer.
* Each test pins a class of bug an audit caught — if any of these starts
* passing through, that's a re-regression.
*/
class HostilePacketInputTest {
/**
* RFC 9000 §17.2 caps CID length at 20 bytes. A hostile peer with
* dcidLen=0xFF would, before the fix, cause `r.readBytes(255)` to
* either crash or read 255 bytes of arbitrary buffer state.
*/
@Test
fun retry_packet_with_oversized_dcid_len_is_rejected() {
// Build a "Retry" with dcidLen=255, scidLen=8 (legal), then 16 bytes of random tag.
val w = com.vitorpamplona.quic.QuicWriter()
w.writeByte(0xff)
w.writeUint32(0x00000001) // version
w.writeByte(0xff) // dcidLen = 255 — illegal
w.writeBytes(ByteArray(8)) // not enough bytes for a 255-byte CID
// Padding so we don't hit other underflow checks before the CID one.
w.writeBytes(ByteArray(40))
val parsed = RetryPacket.parse(w.toByteArray())
assertNull(parsed, "Retry with dcidLen=255 must be rejected")
}
@Test
fun retry_packet_with_oversized_scid_len_is_rejected() {
val w = com.vitorpamplona.quic.QuicWriter()
w.writeByte(0xff)
w.writeUint32(0x00000001)
w.writeByte(0)
w.writeByte(0xff) // scidLen = 255 — illegal
w.writeBytes(ByteArray(40))
val parsed = RetryPacket.parse(w.toByteArray())
assertNull(parsed)
}
/**
* RFC 9001 §5.5: a peer that sends one bad packet inside a coalesced
* datagram must NOT cause subsequent packets to be dropped. Before the
* fix, [feedDatagram]'s `?: break` discarded everything after the first
* unparseable header.
*
* We simulate by feeding a datagram whose Initial packet has bogus
* DCID-len followed by an unrelated trailing packet. peekHeader returns
* null on the bogus first byte → outer loop breaks.
*
* Specifically, this test asserts the post-fix behavior: when peekHeader
* succeeds but decrypt fails, the loop advances by [PeekedHeader.totalLength]
* rather than breaking. We can't easily fake "decrypt-fails-but-peek-succeeds"
* in a unit test without crypto, so this tests the structural invariant
* via [LongHeaderPacket.peekHeader] directly.
*/
@Test
fun peek_header_on_oversized_dcid_returns_null_so_caller_breaks() {
val w = com.vitorpamplona.quic.QuicWriter()
// Long-header type INITIAL, version 1, dcidLen = 21 (illegal: max 20)
w.writeByte(0xC0)
w.writeUint32(0x00000001)
w.writeByte(21)
w.writeBytes(ByteArray(50)) // pretend more bytes
val peeked = LongHeaderPacket.peekHeader(w.toByteArray(), 0)
assertNull(peeked, "peekHeader must reject dcidLen out of [0..20]")
}
@Test
fun peek_header_on_oversized_scid_returns_null() {
val w = com.vitorpamplona.quic.QuicWriter()
w.writeByte(0xC0)
w.writeUint32(0x00000001)
w.writeByte(0) // dcidLen = 0
w.writeByte(21) // scidLen = 21 — illegal
w.writeBytes(ByteArray(50))
val peeked = LongHeaderPacket.peekHeader(w.toByteArray(), 0)
assertNull(peeked)
}
@Test
fun peek_header_on_initial_with_oversized_token_len_returns_null() {
val w = com.vitorpamplona.quic.QuicWriter()
w.writeByte(0xC0)
w.writeUint32(0x00000001)
w.writeByte(0) // dcidLen
w.writeByte(0) // scidLen
// Token length varint: claim 0x4000_0000 (1 GiB)
w.writeVarint(0x4000_0000L)
// No bytes follow — varint length exceeds remaining
val peeked = LongHeaderPacket.peekHeader(w.toByteArray(), 0)
assertNull(peeked, "peekHeader must reject token length > remaining")
}
@Test
fun retry_packet_peek_returns_total_length() {
// A valid Retry: 0xff || 00000001 || 00 || 08 || cid(8) || token(0) || tag(16)
val w = com.vitorpamplona.quic.QuicWriter()
w.writeByte(0xff)
w.writeUint32(0x00000001)
w.writeByte(0)
w.writeByte(8)
w.writeBytes(ByteArray(8))
// Retry token + tag = 16 bytes total (token is empty here for simplicity)
w.writeBytes(ByteArray(16))
val peeked = LongHeaderPacket.peekHeader(w.toByteArray(), 0)
assertTrue(peeked != null, "peekHeader must accept a structurally-valid Retry")
// Total length is bytes.size - offset (no `length` field in Retry)
kotlin.test.assertEquals(w.toByteArray().size, peeked.totalLength)
}
}
@@ -0,0 +1,132 @@
/*
* 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.tls
import com.vitorpamplona.quic.QuicCodecException
import com.vitorpamplona.quic.QuicWriter
import kotlin.test.Test
import kotlin.test.assertFailsWith
/**
* RFC 8446 §4.1.4 — HelloRetryRequest is a ServerHello whose `random` equals
* the SHA-256 of the ASCII string "HelloRetryRequest". We don't implement
* HRR (we offer X25519 only, the group every modern QUIC server accepts).
* Before the round-3 fix, an HRR was treated as a regular ServerHello, then
* X25519 was performed against the cookie/extension bytes (garbage), then
* AEAD failed downstream with a confusing error. The current code rejects
* cleanly with a [QuicCodecException].
*/
class HelloRetryRequestTest {
@Test
fun hello_retry_request_is_rejected_cleanly() {
val tls =
TlsClient(
serverName = "example.test",
transportParameters = ByteArray(0),
secretsListener = NoopSecretsListener,
certificateValidator = null,
)
tls.start()
// Drain (and discard) ClientHello.
tls.pollOutbound(TlsClient.Level.INITIAL)
val hrr = buildHelloRetryRequest()
assertFailsWith<QuicCodecException> {
tls.pushHandshakeBytes(TlsClient.Level.INITIAL, hrr)
}
}
/**
* Build a minimal HRR: handshake type 2 (ServerHello), with the magic
* SHA-256("HelloRetryRequest") random.
*/
private fun buildHelloRetryRequest(): ByteArray {
val helloRetryRequestRandom =
byteArrayOf(
0xCF.toByte(),
0x21.toByte(),
0xAD.toByte(),
0x74.toByte(),
0xE5.toByte(),
0x9A.toByte(),
0x61.toByte(),
0x11.toByte(),
0xBE.toByte(),
0x1D.toByte(),
0x8C.toByte(),
0x02.toByte(),
0x1E.toByte(),
0x65.toByte(),
0xB8.toByte(),
0x91.toByte(),
0xC2.toByte(),
0xA2.toByte(),
0x11.toByte(),
0x16.toByte(),
0x7A.toByte(),
0xBB.toByte(),
0x8C.toByte(),
0x5E.toByte(),
0x07.toByte(),
0x9E.toByte(),
0x09.toByte(),
0xE2.toByte(),
0xC8.toByte(),
0xA8.toByte(),
0x33.toByte(),
0x9C.toByte(),
)
val w = QuicWriter()
w.writeByte(TlsConstants.HS_SERVER_HELLO)
w.withUint24Length {
writeUint16(TlsConstants.LEGACY_VERSION_TLS_1_2)
writeBytes(helloRetryRequestRandom)
writeByte(0) // legacy_session_id_echo: empty
writeUint16(TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256)
writeByte(0) // null compression
withUint16Length {
// supported_versions = TLS 1.3
writeUint16(TlsConstants.EXT_SUPPORTED_VERSIONS)
withUint16Length { writeUint16(TlsConstants.VERSION_TLS_1_3) }
// key_share with empty group (we don't care; HRR check fires first)
writeUint16(TlsConstants.EXT_KEY_SHARE)
withUint16Length { writeUint16(TlsConstants.GROUP_X25519) }
}
}
return w.toByteArray()
}
private object NoopSecretsListener : TlsSecretsListener {
override fun onHandshakeKeysReady(
cipherSuite: Int,
clientSecret: ByteArray,
serverSecret: ByteArray,
) = Unit
override fun onApplicationKeysReady(
cipherSuite: Int,
clientSecret: ByteArray,
serverSecret: ByteArray,
) = Unit
override fun onHandshakeComplete() = Unit
}
}
@@ -0,0 +1,116 @@
/*
* 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.tls
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
/**
* Regression coverage for [TlsTranscriptHash] after the switch from the
* O(n²) "concatenate-and-rehash on every snapshot" implementation to an
* incremental SHA-256 driven by [TlsRunningSha256].
*
* The contract that production TLS code depends on:
*
* 1. snapshot() bytes equal the SHA-256 of all appended bytes in order.
* 2. Multiple snapshots taken at the same point yield identical bytes.
* 3. snapshot() must NOT consume the running hash — further append() calls
* keep extending the same digest. This is the non-obvious bit: a naive
* `digest.digest()` finalizes and resets, so without the clone trick a
* handshake-mid snapshot would silently corrupt later snapshots.
* 4. Output is always 32 bytes.
*/
class TlsTranscriptHashTest {
@Test
fun snapshot_matches_sha256_of_concatenated_bytes() {
val transcript = TlsTranscriptHash()
val msg1 = byteArrayOf(1, 2, 3, 4)
val msg2 = byteArrayOf(5, 6, 7, 8, 9)
transcript.append(msg1)
transcript.append(msg2)
val expected = sha256(msg1 + msg2)
assertContentEquals(expected, transcript.snapshot())
}
@Test
fun empty_transcript_hashes_empty_input() {
val transcript = TlsTranscriptHash()
assertContentEquals(sha256(ByteArray(0)), transcript.snapshot())
}
@Test
fun output_is_always_32_bytes() {
val transcript = TlsTranscriptHash()
assertEquals(32, transcript.snapshot().size)
transcript.append(byteArrayOf(0x42))
assertEquals(32, transcript.snapshot().size)
}
@Test
fun snapshot_does_not_consume_running_state() {
// The bug we're guarding against: `MessageDigest.digest()` finalizes
// and resets. If the implementation accidentally uses that instead of
// cloning, the second snapshot would hash only the post-snapshot
// bytes, not the full transcript. TLS 1.3 takes ≥3 snapshots per
// handshake, so this would corrupt every later key.
val transcript = TlsTranscriptHash()
val ch = byteArrayOf(0x01, 0x00, 0x00, 0x04, 0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte())
val sh = byteArrayOf(0x02, 0x00, 0x00, 0x04, 0xCA.toByte(), 0xFE.toByte(), 0xBA.toByte(), 0xBE.toByte())
val ee = byteArrayOf(0x08, 0x00, 0x00, 0x02, 0x00, 0x00)
transcript.append(ch)
transcript.append(sh)
val handshakeSnapshot = transcript.snapshot()
transcript.append(ee)
val applicationSnapshot = transcript.snapshot()
// Both must be SHA-256 of their respective prefixes.
assertContentEquals(sha256(ch + sh), handshakeSnapshot)
assertContentEquals(sha256(ch + sh + ee), applicationSnapshot)
}
@Test
fun two_snapshots_at_same_position_match() {
val transcript = TlsTranscriptHash()
transcript.append(byteArrayOf(0x10, 0x20, 0x30))
val a = transcript.snapshot()
val b = transcript.snapshot()
assertContentEquals(a, b)
}
@Test
fun snapshots_at_different_positions_differ() {
// Sanity: bumping the transcript should produce a new hash. Catches
// an implementation that accidentally caches and returns a stale
// snapshot.
val transcript = TlsTranscriptHash()
transcript.append(byteArrayOf(0x00))
val before = transcript.snapshot()
transcript.append(byteArrayOf(0x01))
val after = transcript.snapshot()
assertFalse(before.contentEquals(after))
}
}
@@ -0,0 +1,119 @@
/*
* 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.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
/**
* Regression coverage for [CapsuleReader] — the decoder powering peer-initiated
* WT_CLOSE_SESSION detection on the WT CONNECT bidi.
*
* Audit-3 finding: the encoder existed but no decoder consumed the CONNECT
* stream, so a peer-initiated graceful close was silently ignored. These tests
* cover the byte-level decode plus split-chunk/empty-body edge cases that the
* production reader (driven by `QuicStream.incoming.collect`) will hit.
*/
class CapsuleReaderTest {
@Test
fun decodes_complete_close_session_capsule_in_one_push() {
val reader = CapsuleReader()
val capsule = encodeCloseSessionCapsule(errorCode = 7, reason = "bye")
reader.push(capsule)
val first = reader.next()
assertIs<WtCloseSession>(first)
assertEquals(7, first.errorCode)
assertEquals("bye", first.reason)
// No second capsule queued.
assertNull(reader.next())
}
@Test
fun handles_split_chunks_across_push_calls() {
// Chunk the encoded capsule arbitrarily — the QUIC stream callback
// does not respect framing boundaries, so the reader must reassemble.
val capsule = encodeCloseSessionCapsule(errorCode = 42, reason = "split-recv")
val reader = CapsuleReader()
// Push one byte at a time — pathological but tests the buffer logic.
for (b in capsule) {
reader.push(byteArrayOf(b))
}
val parsed = reader.next()
assertIs<WtCloseSession>(parsed)
assertEquals(42, parsed.errorCode)
assertEquals("split-recv", parsed.reason)
}
@Test
fun returns_null_when_buffer_holds_only_partial_capsule() {
val reader = CapsuleReader()
val full = encodeCloseSessionCapsule(0, "x")
// Push everything except the last byte. Reader must wait for more data.
reader.push(full.copyOfRange(0, full.size - 1))
assertNull(reader.next())
// After the final byte arrives, the capsule decodes.
reader.push(byteArrayOf(full.last()))
val parsed = reader.next()
assertIs<WtCloseSession>(parsed)
}
@Test
fun decodes_empty_body_close_session_as_zero_error_empty_reason() {
// Spec lower bound: body length 0, no error code present.
val empty = encodeCapsule(WtCapsuleType.WT_CLOSE_SESSION, ByteArray(0))
val reader = CapsuleReader()
reader.push(empty)
val parsed = reader.next()
assertIs<WtCloseSession>(parsed)
assertEquals(0, parsed.errorCode)
assertEquals("", parsed.reason)
}
@Test
fun unknown_capsule_type_surfaces_as_raw_pair_without_breaking_stream() {
// Server may send capsule types we don't recognise (e.g., DRAIN, future
// extensions). They should not break the reader for subsequent
// recognised capsules.
val reader = CapsuleReader()
val drainBody = byteArrayOf(0x01, 0x02)
reader.push(encodeCapsule(WtCapsuleType.WT_DRAIN_SESSION, drainBody))
reader.push(encodeCloseSessionCapsule(9, "after"))
val first = reader.next()
// Unknown types come back as a Pair<typeVarint, body>.
@Suppress("UNCHECKED_CAST")
val pair = first as Pair<Long, ByteArray>
assertEquals(WtCapsuleType.WT_DRAIN_SESSION, pair.first)
assertEquals(2, pair.second.size)
val second = reader.next()
assertIs<WtCloseSession>(second)
assertEquals(9, second.errorCode)
assertEquals("after", second.reason)
}
}
@@ -0,0 +1,132 @@
/*
* 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.http3.Http3Settings
import com.vitorpamplona.quic.http3.Http3StreamType
import com.vitorpamplona.quic.stream.QuicStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/**
* Verifies the audit-3 fix: GOAWAY frames on the H3 CONTROL stream are now
* decoded into [WtPeerStreamDemux.peerGoawayStreamId] instead of being
* silently dropped.
*
* Drives the demux directly with a fake server-initiated unidirectional
* QuicStream that carries:
* varint(CONTROL) stream-type prefix
* SETTINGS frame any well-formed body
* GOAWAY frame body = single varint stream id
*
* The pre-fix code matched `Http3Frame.Goaway -> Unit`, dropping the body.
* Without this test, the fix could silently regress to the old no-op.
*/
class WtPeerStreamDemuxTest {
@Test
fun control_stream_goaway_sets_peer_goaway_stream_id() {
runBlocking {
// Server-initiated unidirectional stream id (kind == 3 mod 4).
val controlStreamId = 3L
val stream = QuicStream(controlStreamId, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
demux.process(stream)
// Push the bytes the server would have sent on its CONTROL stream:
// 1. Stream-type prefix = 0x00 (CONTROL).
// 2. SETTINGS frame with a single QPACK_MAX_TABLE_CAPACITY=0.
// 3. GOAWAY frame with stream id 12.
val typePrefix = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
val settingsFrame = Http3Settings(emptyMap()).encodeFrame()
val goawayBody = QuicWriter().also { it.writeVarint(12L) }.toByteArray()
val goawayFrame =
QuicWriter()
.apply {
writeVarint(Http3FrameType.GOAWAY)
writeVarint(goawayBody.size.toLong())
writeBytes(goawayBody)
}.toByteArray()
stream.deliverIncoming(typePrefix)
stream.deliverIncoming(settingsFrame)
stream.deliverIncoming(goawayFrame)
// Closing the stream lets the demux's read loop finish — without
// this the test hangs on the chunk channel.
stream.closeIncoming()
// Wait up to a generous bound for the demux coroutine to consume
// the bytes; the actual work is microseconds but JVM scheduling
// jitter can stretch that.
val ok =
withTimeoutOrNull(2_000L) {
while (demux.peerGoawayStreamId == null) delay(5)
true
}
assertEquals(true, ok, "demux should observe GOAWAY within timeout")
assertEquals(12L, demux.peerGoawayStreamId)
assertNotNull(demux.peerSettings, "SETTINGS should also have been captured")
demuxScope.cancel()
}
}
@Test
fun control_stream_without_goaway_leaves_peer_goaway_null() {
runBlocking {
// Same setup minus the GOAWAY frame — peerGoawayStreamId stays null,
// peerSettings still populated. Catches an over-eager fix that
// accidentally fires on SETTINGS or DATA.
val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
demux.process(stream)
val typePrefix = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
val settingsFrame = Http3Settings(emptyMap()).encodeFrame()
stream.deliverIncoming(typePrefix)
stream.deliverIncoming(settingsFrame)
stream.closeIncoming()
val ok =
withTimeoutOrNull(2_000L) {
while (demux.peerSettings == null) delay(5)
true
}
assertEquals(true, ok)
assertNull(demux.peerGoawayStreamId)
demuxScope.cancel()
}
}
}