fix(nestsClient): more bugs from review — IETF parser, leaks, IPv6, URL encoding, structured concurrency

- Unknown IETF control message types now skip just the unknown frame
  instead of dropping the entire merge buffer (a peer sending a
  draft-17 message we don't enumerate — FETCH, GOAWAY, MAX_SUBSCRIBE_ID
  — would otherwise wedge the pump). Adds MoqUnknownTypeException
  carrying bytesConsumed so runControlPump can advance past it.
- pumpUniStreams + pumpInboundBidis wrap their inner collect in
  coroutineScope so per-stream drains are children of the pump's job
  (was: launched on the outer scope as siblings, leaking past
  cancelAndJoin until the transport's own flow errored out).
- parseEndpoint handles IPv6 authorities ([::1], [2001:db8::1]:4443).
  Naive lastIndexOf(':') used to find a colon inside the address.
- buildRelayConnectTarget percent-encodes the namespace path so a
  malicious / careless `d` tag containing `?`, `#`, `&`, ` ` etc.
  can't truncate the URL and shove the JWT into the wrong slot.
- Reconnect orchestrators (listener + speaker) replace
  `runCatching { openOnce / close / startBroadcasting }` with explicit
  try/catch that rethrows CancellationException, so cooperative
  cancellation from the parent scope dies promptly instead of running
  one more iteration after the cancel.

Adds three regression tests:
  - parseEndpoint_handles_IPv6_authorities
  - buildRelayConnectTarget_percent_encodes_path_unsafe_chars
  - unknown_message_type_throws_typed_exception_with_full_frame_size

215 tests pass, 0 failures.
This commit is contained in:
Claude
2026-05-01 18:01:06 +00:00
parent eb20ac2c2a
commit f3a942dbd0
9 changed files with 216 additions and 29 deletions
@@ -167,6 +167,41 @@ class NestsConnectTest {
)
}
@Test
fun parseEndpoint_handles_IPv6_authorities() {
// No port — naive `lastIndexOf(':')` would find a colon inside
// the address; the bracket-aware path keeps `[::1]` whole.
assertEquals(
"[::1]" to "/moq",
parseEndpoint("https://[::1]/moq"),
)
assertEquals(
"[2001:db8::1]:4443" to "/moq",
parseEndpoint("https://[2001:db8::1]:4443/moq"),
)
// Default-port stripping still applies on bracketed authorities.
assertEquals(
"[::1]" to "/",
parseEndpoint("https://[::1]:443/"),
)
}
@Test
fun buildRelayConnectTarget_percent_encodes_path_unsafe_chars() {
// A malicious or careless `d` tag containing `?`, `#`, ` ` etc.
// would otherwise truncate the URL path and shove the JWT into
// the wrong slot. Encoding keeps the namespace literal in path
// position; the relay URL-decodes before comparing to claim root.
val (authority, path) =
buildRelayConnectTarget(
endpoint = "https://relay.example.com/moq",
namespace = "nests/30312:0123:room?weird#frag",
token = "tok-abc",
)
assertEquals("relay.example.com", authority)
assertEquals("/nests/30312:0123:room%3Fweird%23frag?jwt=tok-abc", path)
}
// ---------------------------------------------------------- fakes
private class FakeNestsClient(
@@ -100,12 +100,18 @@ class MoqCodecTest {
}
@Test
fun unknown_message_type_is_rejected() {
// Craft a frame with message type 0xFFFF (large varint), length 0, no payload.
fun unknown_message_type_throws_typed_exception_with_full_frame_size() {
// Craft a frame with message type 0xFFFF (large varint), length 4,
// four bytes of payload. The pump uses bytesConsumed to skip past
// the unknown frame instead of dropping the whole buffer.
val bogus = MoqWriter()
bogus.writeVarint(0xFFFFL)
bogus.writeVarint(0L)
assertFailsWith<MoqCodecException> { MoqCodec.decode(bogus.toByteArray()) }
bogus.writeVarint(4L)
bogus.writeBytes(byteArrayOf(0x01, 0x02, 0x03, 0x04))
val encoded = bogus.toByteArray()
val ex = assertFailsWith<MoqUnknownTypeException> { MoqCodec.decode(encoded) }
assertEquals(0xFFFFL, ex.typeCode)
assertEquals(encoded.size, ex.bytesConsumed)
}
@Test