mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
feat(audio-rooms): moq-lite codec primitives + message types (phase 5a/5b)
First two phases of the moq-lite (Lite-03) implementation per
nestsClient/plans/2026-04-26-moq-lite-gap.md. No production wiring
yet — the production helpers still call the IETF MoqSession.
What landed:
- MoqLitePath — mandatory wire-boundary normalisation (strip
leading/trailing/duplicate `/`), join, path-component-aware
stripPrefix. Mirrors rs/moq-lite/src/path.rs semantics.
- MoqLiteAlpn / MoqLiteControlType / MoqLiteDataType /
MoqLiteAnnounceStatus / MoqLiteSubscribeResponseType — wire
enums for ALPN ("moq-lite-03"), per-bidi ControlType varints,
Group uni-stream type byte, announce status, subscribe
response type.
- Message data classes — AnnouncePlease, Announce, Subscribe,
SubscribeOk, SubscribeDrop, GroupHeader, Probe.
- MoqLiteCodec — encode/decode for each message, with the
size-prefix envelope baked in. Handles the off-by-one
`0 = None, n = Some(n−1)` trick for startGroup/endGroup,
coerces booleans to 0/1, validates priority fits in u8,
rejects unknown status/response bytes. Path normalisation
applied at every encode + decode boundary.
- MoqLitePathTest, MoqLiteCodecTest — round-trip tests for
every codec entry point + negative paths (oversized priority,
invalid ordered byte, unknown status, trailing garbage).
All MoqWriter / MoqReader / MoqCodecException primitives reused
from the IETF MoQ codec — same varint and length-prefix shapes.
This commit is contained in:
+261
@@ -0,0 +1,261 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.nestsclient.moq.lite
|
||||||
|
|
||||||
|
import com.vitorpamplona.nestsclient.moq.MoqCodecException
|
||||||
|
import com.vitorpamplona.nestsclient.moq.MoqReader
|
||||||
|
import com.vitorpamplona.nestsclient.moq.MoqWriter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode/decode moq-lite (Lite-03) messages. Every message on the wire
|
||||||
|
* is **size-prefixed**: a varint length, then the payload bytes.
|
||||||
|
*
|
||||||
|
* This codec produces the size-prefixed envelope on encode and consumes
|
||||||
|
* it on decode. The control-stream framing in `MoqLiteSession` reads
|
||||||
|
* one size-prefix at a time, slices the payload, and dispatches.
|
||||||
|
*
|
||||||
|
* `MoqLitePath.normalize` is applied to every path string at the wire
|
||||||
|
* boundary so a non-normalised input from the application layer can't
|
||||||
|
* silently produce wire bytes that the relay won't match against
|
||||||
|
* `claims.root` or against another peer's broadcast lookup.
|
||||||
|
*
|
||||||
|
* The off-by-one encoding for `startGroup` / `endGroup` (Subscribe
|
||||||
|
* Lite-03 only) is `0 = None, n = Some(n − 1)`. The data-class fields
|
||||||
|
* carry the `Some(value)` form (or null for None); the codec applies
|
||||||
|
* the `+1` / `−1` shift here.
|
||||||
|
*
|
||||||
|
* Source: `kixelated/moq-rs/rs/moq-lite/src/lite/{announce,subscribe,
|
||||||
|
* group}.rs`, the equivalent `@moq/lite/lite/` JS files, and varint
|
||||||
|
* encoding per RFC 9000 §16.
|
||||||
|
*/
|
||||||
|
object MoqLiteCodec {
|
||||||
|
// ---------------- AnnouncePlease ----------------
|
||||||
|
|
||||||
|
fun encodeAnnouncePlease(msg: MoqLiteAnnouncePlease): ByteArray {
|
||||||
|
val body = MoqWriter()
|
||||||
|
body.writeLengthPrefixedString(MoqLitePath.normalize(msg.prefix))
|
||||||
|
return wrapSizePrefixed(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeAnnouncePlease(payload: ByteArray): MoqLiteAnnouncePlease {
|
||||||
|
val r = MoqReader(payload)
|
||||||
|
val prefix = MoqLitePath.normalize(r.readLengthPrefixedString())
|
||||||
|
ensureFullyConsumed(r, "AnnouncePlease")
|
||||||
|
return MoqLiteAnnouncePlease(prefix = prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Announce ----------------
|
||||||
|
|
||||||
|
fun encodeAnnounce(msg: MoqLiteAnnounce): ByteArray {
|
||||||
|
val body = MoqWriter()
|
||||||
|
body.writeByte(msg.status.code)
|
||||||
|
body.writeLengthPrefixedString(MoqLitePath.normalize(msg.suffix))
|
||||||
|
body.writeVarint(msg.hops)
|
||||||
|
return wrapSizePrefixed(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeAnnounce(payload: ByteArray): MoqLiteAnnounce {
|
||||||
|
val r = MoqReader(payload)
|
||||||
|
val statusByte = r.readByte()
|
||||||
|
val status =
|
||||||
|
MoqLiteAnnounceStatus.fromCode(statusByte)
|
||||||
|
?: throw MoqCodecException("unknown moq-lite Announce status byte: $statusByte")
|
||||||
|
val suffix = MoqLitePath.normalize(r.readLengthPrefixedString())
|
||||||
|
val hops = r.readVarint()
|
||||||
|
ensureFullyConsumed(r, "Announce")
|
||||||
|
return MoqLiteAnnounce(status = status, suffix = suffix, hops = hops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Subscribe ----------------
|
||||||
|
|
||||||
|
fun encodeSubscribe(msg: MoqLiteSubscribe): ByteArray {
|
||||||
|
val body = MoqWriter()
|
||||||
|
body.writeVarint(msg.id)
|
||||||
|
body.writeLengthPrefixedString(MoqLitePath.normalize(msg.broadcast))
|
||||||
|
body.writeLengthPrefixedString(msg.track) // tracks are opaque, no normalize
|
||||||
|
body.writeByte(msg.priority)
|
||||||
|
body.writeByte(if (msg.ordered) 1 else 0)
|
||||||
|
body.writeVarint(msg.maxLatencyMillis)
|
||||||
|
body.writeVarint(encodeOptionalGroup(msg.startGroup))
|
||||||
|
body.writeVarint(encodeOptionalGroup(msg.endGroup))
|
||||||
|
return wrapSizePrefixed(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeSubscribe(payload: ByteArray): MoqLiteSubscribe {
|
||||||
|
val r = MoqReader(payload)
|
||||||
|
val id = r.readVarint()
|
||||||
|
val broadcast = MoqLitePath.normalize(r.readLengthPrefixedString())
|
||||||
|
val track = r.readLengthPrefixedString()
|
||||||
|
val priority = r.readByte()
|
||||||
|
val ordered = decodeOrderedByte(r.readByte())
|
||||||
|
val maxLatencyMillis = r.readVarint()
|
||||||
|
val startGroup = decodeOptionalGroup(r.readVarint())
|
||||||
|
val endGroup = decodeOptionalGroup(r.readVarint())
|
||||||
|
ensureFullyConsumed(r, "Subscribe")
|
||||||
|
return MoqLiteSubscribe(
|
||||||
|
id = id,
|
||||||
|
broadcast = broadcast,
|
||||||
|
track = track,
|
||||||
|
priority = priority,
|
||||||
|
ordered = ordered,
|
||||||
|
maxLatencyMillis = maxLatencyMillis,
|
||||||
|
startGroup = startGroup,
|
||||||
|
endGroup = endGroup,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Subscribe response ----------------
|
||||||
|
|
||||||
|
fun encodeSubscribeOk(msg: MoqLiteSubscribeOk): ByteArray {
|
||||||
|
val body = MoqWriter()
|
||||||
|
body.writeVarint(MoqLiteSubscribeResponseType.Ok.code)
|
||||||
|
body.writeByte(msg.priority)
|
||||||
|
body.writeByte(if (msg.ordered) 1 else 0)
|
||||||
|
body.writeVarint(msg.maxLatencyMillis)
|
||||||
|
body.writeVarint(encodeOptionalGroup(msg.startGroup))
|
||||||
|
body.writeVarint(encodeOptionalGroup(msg.endGroup))
|
||||||
|
return wrapSizePrefixed(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encodeSubscribeDrop(msg: MoqLiteSubscribeDrop): ByteArray {
|
||||||
|
val body = MoqWriter()
|
||||||
|
body.writeVarint(MoqLiteSubscribeResponseType.Drop.code)
|
||||||
|
body.writeVarint(msg.errorCode)
|
||||||
|
body.writeLengthPrefixedString(msg.reasonPhrase)
|
||||||
|
return wrapSizePrefixed(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode either an [MoqLiteSubscribeOk] or [MoqLiteSubscribeDrop]
|
||||||
|
* — the response stream tells which by its leading varint type.
|
||||||
|
*/
|
||||||
|
sealed class SubscribeResponse {
|
||||||
|
data class Ok(
|
||||||
|
val ok: MoqLiteSubscribeOk,
|
||||||
|
) : SubscribeResponse()
|
||||||
|
|
||||||
|
data class Dropped(
|
||||||
|
val drop: MoqLiteSubscribeDrop,
|
||||||
|
) : SubscribeResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeSubscribeResponse(payload: ByteArray): SubscribeResponse {
|
||||||
|
val r = MoqReader(payload)
|
||||||
|
val typeCode = r.readVarint()
|
||||||
|
val type =
|
||||||
|
MoqLiteSubscribeResponseType.fromCode(typeCode)
|
||||||
|
?: throw MoqCodecException("unknown moq-lite SubscribeResponse type: $typeCode")
|
||||||
|
return when (type) {
|
||||||
|
MoqLiteSubscribeResponseType.Ok -> {
|
||||||
|
val priority = r.readByte()
|
||||||
|
val ordered = decodeOrderedByte(r.readByte())
|
||||||
|
val maxLatencyMillis = r.readVarint()
|
||||||
|
val startGroup = decodeOptionalGroup(r.readVarint())
|
||||||
|
val endGroup = decodeOptionalGroup(r.readVarint())
|
||||||
|
ensureFullyConsumed(r, "SubscribeOk")
|
||||||
|
SubscribeResponse.Ok(
|
||||||
|
MoqLiteSubscribeOk(
|
||||||
|
priority = priority,
|
||||||
|
ordered = ordered,
|
||||||
|
maxLatencyMillis = maxLatencyMillis,
|
||||||
|
startGroup = startGroup,
|
||||||
|
endGroup = endGroup,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MoqLiteSubscribeResponseType.Drop -> {
|
||||||
|
val errorCode = r.readVarint()
|
||||||
|
val reason = r.readLengthPrefixedString()
|
||||||
|
ensureFullyConsumed(r, "SubscribeDrop")
|
||||||
|
SubscribeResponse.Dropped(
|
||||||
|
MoqLiteSubscribeDrop(errorCode = errorCode, reasonPhrase = reason),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Group header ----------------
|
||||||
|
|
||||||
|
fun encodeGroupHeader(msg: MoqLiteGroupHeader): ByteArray {
|
||||||
|
val body = MoqWriter()
|
||||||
|
body.writeVarint(msg.subscribeId)
|
||||||
|
body.writeVarint(msg.sequence)
|
||||||
|
return wrapSizePrefixed(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeGroupHeader(payload: ByteArray): MoqLiteGroupHeader {
|
||||||
|
val r = MoqReader(payload)
|
||||||
|
val subscribeId = r.readVarint()
|
||||||
|
val sequence = r.readVarint()
|
||||||
|
ensureFullyConsumed(r, "GroupHeader")
|
||||||
|
return MoqLiteGroupHeader(subscribeId = subscribeId, sequence = sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Probe ----------------
|
||||||
|
|
||||||
|
fun decodeProbe(payload: ByteArray): MoqLiteProbe {
|
||||||
|
val r = MoqReader(payload)
|
||||||
|
val bitrate = r.readVarint()
|
||||||
|
ensureFullyConsumed(r, "Probe")
|
||||||
|
return MoqLiteProbe(bitrate = bitrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- internals ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a body buffer in a varint size prefix. Every moq-lite
|
||||||
|
* control-stream message uses this envelope (see e.g.
|
||||||
|
* `lite/announce.rs:64-81`, `lite/subscribe.rs:25-72`).
|
||||||
|
*/
|
||||||
|
private fun wrapSizePrefixed(body: MoqWriter): ByteArray {
|
||||||
|
val payload = body.toByteArray()
|
||||||
|
val out = MoqWriter(payload.size + 8)
|
||||||
|
out.writeLengthPrefixedBytes(payload)
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `0 = None, n = Some(n − 1)`. The encoding lets the wire format
|
||||||
|
* collapse "no bound" into a single zero byte.
|
||||||
|
*/
|
||||||
|
private fun encodeOptionalGroup(value: Long?): Long = if (value == null) 0L else value + 1
|
||||||
|
|
||||||
|
private fun decodeOptionalGroup(raw: Long): Long? = if (raw == 0L) null else raw - 1
|
||||||
|
|
||||||
|
private fun decodeOrderedByte(b: Int): Boolean =
|
||||||
|
when (b) {
|
||||||
|
0 -> false
|
||||||
|
1 -> true
|
||||||
|
else -> throw MoqCodecException("moq-lite ordered byte must be 0/1, got $b")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureFullyConsumed(
|
||||||
|
r: MoqReader,
|
||||||
|
msg: String,
|
||||||
|
) {
|
||||||
|
if (r.hasMore()) {
|
||||||
|
throw MoqCodecException(
|
||||||
|
"trailing $msg payload bytes (${r.remaining} left) — wire format mismatch",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+239
@@ -0,0 +1,239 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.nestsclient.moq.lite
|
||||||
|
|
||||||
|
/**
|
||||||
|
* moq-lite ALPN strings. Lite-03 is preferred; `"moql"` is the legacy
|
||||||
|
* combined ALPN that requires an in-band SETUP exchange.
|
||||||
|
*
|
||||||
|
* Source: `kixelated/moq-rs/rs/moq-lite/src/version.rs:21-26`,
|
||||||
|
* `@moq/lite/connection/connect.js:277`.
|
||||||
|
*/
|
||||||
|
object MoqLiteAlpn {
|
||||||
|
const val LITE_03: String = "moq-lite-03"
|
||||||
|
const val LEGACY: String = "moql"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ControlType varint discriminator written as the first datum on every
|
||||||
|
* client-initiated bidi stream. Selects which message body the peer
|
||||||
|
* should expect to read next.
|
||||||
|
*
|
||||||
|
* Source: `rs/moq-lite/src/lite/stream.rs:7-15`.
|
||||||
|
*/
|
||||||
|
enum class MoqLiteControlType(
|
||||||
|
val code: Long,
|
||||||
|
) {
|
||||||
|
/** Lite-01/02 only — unused on Lite-03. Reserved here for completeness. */
|
||||||
|
Session(0L),
|
||||||
|
Announce(1L),
|
||||||
|
Subscribe(2L),
|
||||||
|
Fetch(3L),
|
||||||
|
Probe(4L),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val byCode = entries.associateBy { it.code }
|
||||||
|
|
||||||
|
fun fromCode(code: Long): MoqLiteControlType? = byCode[code]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataType varint written as the first byte of every uni stream that
|
||||||
|
* carries payload. Lite-03 currently uses `Group=0` only.
|
||||||
|
*
|
||||||
|
* Source: `rs/moq-lite/src/lite/stream.rs:32-36`.
|
||||||
|
*/
|
||||||
|
enum class MoqLiteDataType(
|
||||||
|
val code: Long,
|
||||||
|
) {
|
||||||
|
Group(0L),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val byCode = entries.associateBy { it.code }
|
||||||
|
|
||||||
|
fun fromCode(code: Long): MoqLiteDataType? = byCode[code]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status byte at the head of an [MoqLiteAnnounce] payload. `Active=1`
|
||||||
|
* is sent on first publish; `Ended=0` on explicit unannounce. Disconnect
|
||||||
|
* is NOT signalled with `Ended` — the bidi just closes.
|
||||||
|
*
|
||||||
|
* Source: `rs/moq-lite/src/lite/announce.rs:84-90`.
|
||||||
|
*/
|
||||||
|
enum class MoqLiteAnnounceStatus(
|
||||||
|
val code: Int,
|
||||||
|
) {
|
||||||
|
Ended(0),
|
||||||
|
Active(1),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromCode(code: Int): MoqLiteAnnounceStatus? =
|
||||||
|
when (code) {
|
||||||
|
0 -> Ended
|
||||||
|
1 -> Active
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type byte at the head of a [MoqLiteSubscribeResponse] payload.
|
||||||
|
*
|
||||||
|
* Source: `rs/moq-lite/src/lite/subscribe.rs:271-328`,
|
||||||
|
* `@moq/lite/lite/subscribe.js:264-282`.
|
||||||
|
*/
|
||||||
|
enum class MoqLiteSubscribeResponseType(
|
||||||
|
val code: Long,
|
||||||
|
) {
|
||||||
|
Ok(0L),
|
||||||
|
Drop(1L),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val byCode = entries.associateBy { it.code }
|
||||||
|
|
||||||
|
fun fromCode(code: Long): MoqLiteSubscribeResponseType? = byCode[code]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "I'm interested in broadcasts under this prefix" — the first message
|
||||||
|
* the subscriber writes on an Announce bidi.
|
||||||
|
*
|
||||||
|
* Wire layout (size-prefixed):
|
||||||
|
* prefix string (varint length + UTF-8)
|
||||||
|
*
|
||||||
|
* Empty prefix means "everything".
|
||||||
|
*/
|
||||||
|
data class MoqLiteAnnouncePlease(
|
||||||
|
val prefix: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-broadcast announce update streamed by the publisher (or relay)
|
||||||
|
* back to the subscriber on the same Announce bidi. One message per
|
||||||
|
* known broadcast at attach time, then live updates as broadcasts come
|
||||||
|
* and go.
|
||||||
|
*
|
||||||
|
* Wire layout (size-prefixed):
|
||||||
|
* status u8 (0 = Ended, 1 = Active)
|
||||||
|
* suffix string (broadcast path with the requested `prefix`
|
||||||
|
* stripped — `MoqLitePath.join(prefix, suffix)`
|
||||||
|
* reconstitutes the absolute path)
|
||||||
|
* hops u62 (relay routing depth, Lite-03 only)
|
||||||
|
*/
|
||||||
|
data class MoqLiteAnnounce(
|
||||||
|
val status: MoqLiteAnnounceStatus,
|
||||||
|
val suffix: String,
|
||||||
|
val hops: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Subscribe me to (broadcast, track)" — the first message the
|
||||||
|
* subscriber writes on a Subscribe bidi.
|
||||||
|
*
|
||||||
|
* Wire layout (size-prefixed):
|
||||||
|
* id u62 varint (subscriber-chosen, monotonically increasing)
|
||||||
|
* broadcast string (absolute broadcast path, normalized)
|
||||||
|
* track string (opaque app string — `"audio/data"` /
|
||||||
|
* `"catalog.json"` for nests)
|
||||||
|
* priority u8 (raw byte 0..255)
|
||||||
|
* ordered u8 (Lite-03; 0/1)
|
||||||
|
* maxLatency varint (Lite-03; *milliseconds*; 0 = unlimited)
|
||||||
|
* startGroup varint (Lite-03; 0 = "from latest",
|
||||||
|
* else group_seq + 1)
|
||||||
|
* endGroup varint (Lite-03; 0 = "no end",
|
||||||
|
* else group_seq + 1)
|
||||||
|
*/
|
||||||
|
data class MoqLiteSubscribe(
|
||||||
|
val id: Long,
|
||||||
|
val broadcast: String,
|
||||||
|
val track: String,
|
||||||
|
val priority: Int,
|
||||||
|
val ordered: Boolean,
|
||||||
|
val maxLatencyMillis: Long,
|
||||||
|
val startGroup: Long?,
|
||||||
|
val endGroup: Long?,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(priority in 0..255) { "moq-lite priority must fit in a byte: $priority" }
|
||||||
|
require(maxLatencyMillis >= 0) { "maxLatencyMillis must be non-negative: $maxLatencyMillis" }
|
||||||
|
if (startGroup != null) require(startGroup >= 0) { "startGroup must be non-negative: $startGroup" }
|
||||||
|
if (endGroup != null) require(endGroup >= 0) { "endGroup must be non-negative: $endGroup" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publisher's accept reply to a [MoqLiteSubscribe]. Echoes the
|
||||||
|
* negotiated subscription parameters; the publisher may have narrowed
|
||||||
|
* `startGroup` / `endGroup` from the subscriber's request.
|
||||||
|
*/
|
||||||
|
data class MoqLiteSubscribeOk(
|
||||||
|
val priority: Int,
|
||||||
|
val ordered: Boolean,
|
||||||
|
val maxLatencyMillis: Long,
|
||||||
|
val startGroup: Long?,
|
||||||
|
val endGroup: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publisher's reject / drop reply. moq-lite has no SUBSCRIBE_ERROR
|
||||||
|
* code; failures during a live subscription are conveyed by
|
||||||
|
* RESET_STREAM, but the publisher can pre-emptively decline a
|
||||||
|
* subscription with [MoqLiteSubscribeDrop] before any group flows.
|
||||||
|
*
|
||||||
|
* Decode-only as far as the client cares — we never send Drop back
|
||||||
|
* upstream.
|
||||||
|
*/
|
||||||
|
data class MoqLiteSubscribeDrop(
|
||||||
|
val errorCode: Long,
|
||||||
|
val reasonPhrase: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header at the start of a Group uni stream. After the
|
||||||
|
* [MoqLiteDataType.Group] type byte, the publisher writes one
|
||||||
|
* size-prefixed [MoqLiteGroupHeader] payload, then a sequence of
|
||||||
|
* `varint(size) + payload` frames until QUIC FIN.
|
||||||
|
*/
|
||||||
|
data class MoqLiteGroupHeader(
|
||||||
|
val subscribeId: Long,
|
||||||
|
val sequence: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe message written by the *publisher* on a subscriber-initiated
|
||||||
|
* Probe bidi (ControlType=4). `bitrate` is encoded as a u62 varint in
|
||||||
|
* a size-prefixed body. Decode-only for now — the listener path
|
||||||
|
* exposes the most recent reading, and we don't initiate probes from
|
||||||
|
* the speaker side.
|
||||||
|
*
|
||||||
|
* Source: `rs/moq-lite/src/lite/probe.rs`.
|
||||||
|
*/
|
||||||
|
data class MoqLiteProbe(
|
||||||
|
val bitrate: Long,
|
||||||
|
)
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.nestsclient.moq.lite
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mandatory path-normalization for moq-lite (Lite-03), mirroring
|
||||||
|
* `kixelated/moq-rs/rs/moq-lite/src/path.rs`. moq-lite represents
|
||||||
|
* broadcast and announce paths as plain UTF-8 strings; the wire format
|
||||||
|
* is `varint(length) + UTF-8 bytes`. Both peers are required to
|
||||||
|
* normalize before encoding and after decoding — without that, a wire
|
||||||
|
* path `"/foo//bar/"` and `"foo/bar"` would be treated as distinct and
|
||||||
|
* broadcast lookups silently fail.
|
||||||
|
*
|
||||||
|
* Normalization rules (mirroring `Path::new` semantics):
|
||||||
|
* - strip every leading `/`
|
||||||
|
* - strip every trailing `/`
|
||||||
|
* - collapse runs of `/` into a single `/`
|
||||||
|
*
|
||||||
|
* Empty paths are valid (the relay's "everything under root" prefix).
|
||||||
|
*/
|
||||||
|
object MoqLitePath {
|
||||||
|
/**
|
||||||
|
* Apply the moq-lite normalization rules. Returns the canonical path
|
||||||
|
* string for [s].
|
||||||
|
*/
|
||||||
|
fun normalize(s: String): String {
|
||||||
|
if (s.isEmpty()) return s
|
||||||
|
val out = StringBuilder(s.length)
|
||||||
|
var lastWasSlash = true
|
||||||
|
for (i in s.indices) {
|
||||||
|
val c = s[i]
|
||||||
|
if (c == '/') {
|
||||||
|
if (!lastWasSlash) out.append('/')
|
||||||
|
lastWasSlash = true
|
||||||
|
} else {
|
||||||
|
out.append(c)
|
||||||
|
lastWasSlash = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strip the trailing `/` left behind by an input ending in `/`.
|
||||||
|
if (out.isNotEmpty() && out[out.length - 1] == '/') {
|
||||||
|
out.deleteCharAt(out.length - 1)
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concatenate two path components per moq-lite semantics. Each side
|
||||||
|
* is normalized first; if either is empty, returns the other (no
|
||||||
|
* leading or trailing `/`). Otherwise emits `"<prefix>/<suffix>"`.
|
||||||
|
*/
|
||||||
|
fun join(
|
||||||
|
prefix: String,
|
||||||
|
suffix: String,
|
||||||
|
): String {
|
||||||
|
val a = normalize(prefix)
|
||||||
|
val b = normalize(suffix)
|
||||||
|
if (a.isEmpty()) return b
|
||||||
|
if (b.isEmpty()) return a
|
||||||
|
return "$a/$b"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If [path] starts with [prefix] (path-component-aware — `"foo"` does
|
||||||
|
* NOT match `"foobar"`), return the suffix that remains. Returns
|
||||||
|
* null if the prefix does not match.
|
||||||
|
*
|
||||||
|
* Both inputs are normalized before comparison.
|
||||||
|
*/
|
||||||
|
fun stripPrefix(
|
||||||
|
prefix: String,
|
||||||
|
path: String,
|
||||||
|
): String? {
|
||||||
|
val p = normalize(prefix)
|
||||||
|
val full = normalize(path)
|
||||||
|
if (p.isEmpty()) return full
|
||||||
|
if (full == p) return ""
|
||||||
|
if (full.length > p.length && full.startsWith(p) && full[p.length] == '/') {
|
||||||
|
return full.substring(p.length + 1)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.nestsclient.moq.lite
|
||||||
|
|
||||||
|
import com.vitorpamplona.nestsclient.moq.MoqCodecException
|
||||||
|
import com.vitorpamplona.nestsclient.moq.MoqReader
|
||||||
|
import com.vitorpamplona.quic.Varint
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode/decode round-trip tests for every moq-lite (Lite-03) message
|
||||||
|
* type the client uses. Each test encodes a hand-built data class to
|
||||||
|
* bytes, peels off the size-prefix to confirm the framing envelope,
|
||||||
|
* then decodes the payload and asserts equality with the input.
|
||||||
|
*
|
||||||
|
* Wire-format expectations are anchored against the byte layout
|
||||||
|
* documented in `nestsClient/plans/2026-04-26-moq-lite-gap.md` (which
|
||||||
|
* cites `kixelated/moq-rs/rs/moq-lite/src/`).
|
||||||
|
*/
|
||||||
|
class MoqLiteCodecTest {
|
||||||
|
@Test
|
||||||
|
fun announcePlease_round_trips() {
|
||||||
|
val msg = MoqLiteAnnouncePlease(prefix = "nests/30312:abc:room")
|
||||||
|
val encoded = MoqLiteCodec.encodeAnnouncePlease(msg)
|
||||||
|
val payload = peelSizePrefix(encoded)
|
||||||
|
assertEquals(msg, MoqLiteCodec.decodeAnnouncePlease(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun announcePlease_normalizes_prefix_on_encode_and_decode() {
|
||||||
|
val raw = MoqLiteAnnouncePlease(prefix = "/nests//30312:abc:room/")
|
||||||
|
val payload = peelSizePrefix(MoqLiteCodec.encodeAnnouncePlease(raw))
|
||||||
|
// The wire bytes are normalised — no leading/trailing/duplicate `/`.
|
||||||
|
val r = MoqReader(payload)
|
||||||
|
assertEquals("nests/30312:abc:room", r.readLengthPrefixedString())
|
||||||
|
assertTrue(!r.hasMore())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun announce_active_round_trips() {
|
||||||
|
val msg = MoqLiteAnnounce(status = MoqLiteAnnounceStatus.Active, suffix = "speakerPubkey", hops = 0L)
|
||||||
|
val payload = peelSizePrefix(MoqLiteCodec.encodeAnnounce(msg))
|
||||||
|
// Byte 0 is the status byte (literal 1), then a 13-byte string,
|
||||||
|
// then a varint hops.
|
||||||
|
assertEquals(1, payload[0].toInt())
|
||||||
|
assertEquals(msg, MoqLiteCodec.decodeAnnounce(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun announce_ended_with_relay_hops_round_trips() {
|
||||||
|
val msg = MoqLiteAnnounce(status = MoqLiteAnnounceStatus.Ended, suffix = "fff", hops = 7L)
|
||||||
|
val payload = peelSizePrefix(MoqLiteCodec.encodeAnnounce(msg))
|
||||||
|
assertEquals(0, payload[0].toInt())
|
||||||
|
assertEquals(msg, MoqLiteCodec.decodeAnnounce(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun announce_rejects_unknown_status_byte() {
|
||||||
|
// Hand-craft a payload with an invalid status byte. Wrap in the
|
||||||
|
// size-prefix envelope just like a real message would arrive.
|
||||||
|
val handCraft = byteArrayOf(0x05.toByte()) + lengthPrefixed("x") + Varint.encode(0L)
|
||||||
|
assertFailsWith<MoqCodecException> { MoqLiteCodec.decodeAnnounce(handCraft) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subscribe_round_trips_with_all_groups_set() {
|
||||||
|
val msg =
|
||||||
|
MoqLiteSubscribe(
|
||||||
|
id = 42L,
|
||||||
|
broadcast = "nests/30312:abc:room",
|
||||||
|
track = "audio/data",
|
||||||
|
priority = 0x80,
|
||||||
|
ordered = true,
|
||||||
|
maxLatencyMillis = 250L,
|
||||||
|
startGroup = 5L,
|
||||||
|
endGroup = 9L,
|
||||||
|
)
|
||||||
|
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribe(msg))
|
||||||
|
assertEquals(msg, MoqLiteCodec.decodeSubscribe(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subscribe_round_trips_with_null_group_bounds() {
|
||||||
|
val msg =
|
||||||
|
MoqLiteSubscribe(
|
||||||
|
id = 1L,
|
||||||
|
broadcast = "speakerPubkey",
|
||||||
|
track = "catalog.json",
|
||||||
|
priority = 0,
|
||||||
|
ordered = false,
|
||||||
|
maxLatencyMillis = 0L,
|
||||||
|
startGroup = null,
|
||||||
|
endGroup = null,
|
||||||
|
)
|
||||||
|
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribe(msg))
|
||||||
|
assertEquals(msg, MoqLiteCodec.decodeSubscribe(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subscribe_off_by_one_group_encoding_uses_zero_for_none() {
|
||||||
|
val msg = subscribeWith(startGroup = null, endGroup = null)
|
||||||
|
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribe(msg))
|
||||||
|
// Skip past id (varint), broadcast/track strings, priority byte,
|
||||||
|
// ordered byte, maxLatency varint — pull the last two varints.
|
||||||
|
val r = MoqReader(payload)
|
||||||
|
r.readVarint() // id
|
||||||
|
r.readLengthPrefixedString() // broadcast
|
||||||
|
r.readLengthPrefixedString() // track
|
||||||
|
r.readByte() // priority
|
||||||
|
r.readByte() // ordered
|
||||||
|
r.readVarint() // maxLatency
|
||||||
|
assertEquals(0L, r.readVarint(), "startGroup=None encodes as 0 on the wire")
|
||||||
|
assertEquals(0L, r.readVarint(), "endGroup=None encodes as 0 on the wire")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subscribe_off_by_one_some_zero_encodes_as_one() {
|
||||||
|
val msg = subscribeWith(startGroup = 0L, endGroup = 0L)
|
||||||
|
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribe(msg))
|
||||||
|
val r = MoqReader(payload)
|
||||||
|
r.readVarint()
|
||||||
|
r.readLengthPrefixedString()
|
||||||
|
r.readLengthPrefixedString()
|
||||||
|
r.readByte()
|
||||||
|
r.readByte()
|
||||||
|
r.readVarint()
|
||||||
|
assertEquals(1L, r.readVarint(), "Some(0) encodes as 1 (off-by-one trick)")
|
||||||
|
assertEquals(1L, r.readVarint(), "Some(0) encodes as 1 (off-by-one trick)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subscribe_rejects_oversized_priority_at_construction() {
|
||||||
|
assertFailsWith<IllegalArgumentException> {
|
||||||
|
MoqLiteSubscribe(
|
||||||
|
id = 0L,
|
||||||
|
broadcast = "x",
|
||||||
|
track = "y",
|
||||||
|
priority = 256,
|
||||||
|
ordered = false,
|
||||||
|
maxLatencyMillis = 0L,
|
||||||
|
startGroup = null,
|
||||||
|
endGroup = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subscribeOk_round_trips() {
|
||||||
|
val msg =
|
||||||
|
MoqLiteSubscribeOk(
|
||||||
|
priority = 0x80,
|
||||||
|
ordered = true,
|
||||||
|
maxLatencyMillis = 100L,
|
||||||
|
startGroup = null,
|
||||||
|
endGroup = null,
|
||||||
|
)
|
||||||
|
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribeOk(msg))
|
||||||
|
val resp = MoqLiteCodec.decodeSubscribeResponse(payload)
|
||||||
|
val ok = assertIs<MoqLiteCodec.SubscribeResponse.Ok>(resp)
|
||||||
|
assertEquals(msg, ok.ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subscribeDrop_round_trips() {
|
||||||
|
val msg = MoqLiteSubscribeDrop(errorCode = 0x12L, reasonPhrase = "publisher gone")
|
||||||
|
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribeDrop(msg))
|
||||||
|
val resp = MoqLiteCodec.decodeSubscribeResponse(payload)
|
||||||
|
val drop = assertIs<MoqLiteCodec.SubscribeResponse.Dropped>(resp)
|
||||||
|
assertEquals(msg, drop.drop)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun groupHeader_round_trips() {
|
||||||
|
val msg = MoqLiteGroupHeader(subscribeId = 42L, sequence = 7L)
|
||||||
|
val payload = peelSizePrefix(MoqLiteCodec.encodeGroupHeader(msg))
|
||||||
|
assertEquals(msg, MoqLiteCodec.decodeGroupHeader(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoders_reject_trailing_garbage() {
|
||||||
|
// Build a valid AnnouncePlease payload then append a stray byte.
|
||||||
|
val payload = peelSizePrefix(MoqLiteCodec.encodeAnnouncePlease(MoqLiteAnnouncePlease("foo")))
|
||||||
|
val tampered = payload + byteArrayOf(0)
|
||||||
|
assertFailsWith<MoqCodecException> { MoqLiteCodec.decodeAnnouncePlease(tampered) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decoders_reject_invalid_ordered_byte() {
|
||||||
|
val msg = subscribeWith(startGroup = null, endGroup = null)
|
||||||
|
val payload = peelSizePrefix(MoqLiteCodec.encodeSubscribe(msg)).copyOf()
|
||||||
|
// Patch the ordered byte. Layout: varint id (1B for id=0),
|
||||||
|
// string broadcast (1B len + bytes), string track (1B len + bytes),
|
||||||
|
// priority byte, ordered byte. So index = 1 + 1 + b.length + 1 + t.length + 1.
|
||||||
|
val idx = 1 + 1 + msg.broadcast.length + 1 + msg.track.length + 1
|
||||||
|
payload[idx] = 7
|
||||||
|
assertFailsWith<MoqCodecException> { MoqLiteCodec.decodeSubscribe(payload) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun subscribeWith(
|
||||||
|
startGroup: Long?,
|
||||||
|
endGroup: Long?,
|
||||||
|
) = MoqLiteSubscribe(
|
||||||
|
id = 0L,
|
||||||
|
broadcast = "b",
|
||||||
|
track = "t",
|
||||||
|
priority = 0,
|
||||||
|
ordered = false,
|
||||||
|
maxLatencyMillis = 0L,
|
||||||
|
startGroup = startGroup,
|
||||||
|
endGroup = endGroup,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun lengthPrefixed(s: String): ByteArray {
|
||||||
|
val bytes = s.encodeToByteArray()
|
||||||
|
return Varint.encode(bytes.size.toLong()) + bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peel the varint size-prefix off an encoded message. Tests work
|
||||||
|
* directly on the *payload* (no envelope) since the session-layer
|
||||||
|
* is the one that frames the envelope onto/off the wire.
|
||||||
|
*/
|
||||||
|
private fun peelSizePrefix(encoded: ByteArray): ByteArray {
|
||||||
|
val sizeDecode = Varint.decode(encoded) ?: error("envelope missing size prefix")
|
||||||
|
val payload = encoded.copyOfRange(sizeDecode.bytesConsumed, encoded.size)
|
||||||
|
assertEquals(
|
||||||
|
sizeDecode.value.toInt(),
|
||||||
|
payload.size,
|
||||||
|
"size prefix must equal payload length",
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
private fun byteHex(bytes: ByteArray): String = bytes.joinToString(" ") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checks_byteHex_helper_compiles() {
|
||||||
|
assertContentEquals(byteArrayOf(0x10, 0x20), byteArrayOf(0x10, 0x20))
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.nestsclient.moq.lite
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class MoqLitePathTest {
|
||||||
|
@Test
|
||||||
|
fun normalize_strips_leading_trailing_and_collapses_duplicates() {
|
||||||
|
assertEquals("foo/bar", MoqLitePath.normalize("/foo/bar/"))
|
||||||
|
assertEquals("foo/bar", MoqLitePath.normalize("foo//bar"))
|
||||||
|
assertEquals("foo/bar", MoqLitePath.normalize("///foo///bar///"))
|
||||||
|
assertEquals("foo", MoqLitePath.normalize("/foo/"))
|
||||||
|
assertEquals("", MoqLitePath.normalize(""))
|
||||||
|
assertEquals("", MoqLitePath.normalize("/"))
|
||||||
|
assertEquals("", MoqLitePath.normalize("/////"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun normalize_preserves_already_canonical_paths() {
|
||||||
|
assertEquals("foo/bar", MoqLitePath.normalize("foo/bar"))
|
||||||
|
assertEquals(
|
||||||
|
"nests/30312:abc:room/${"f".repeat(64)}",
|
||||||
|
MoqLitePath.normalize("nests/30312:abc:room/${"f".repeat(64)}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun join_handles_empty_sides() {
|
||||||
|
assertEquals("foo", MoqLitePath.join("", "foo"))
|
||||||
|
assertEquals("foo", MoqLitePath.join("foo", ""))
|
||||||
|
assertEquals("", MoqLitePath.join("", ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun join_inserts_exactly_one_slash() {
|
||||||
|
assertEquals("a/b", MoqLitePath.join("a", "b"))
|
||||||
|
// Sides are normalized first — the join can't produce `//`.
|
||||||
|
assertEquals("a/b", MoqLitePath.join("a/", "/b"))
|
||||||
|
assertEquals("nests/30312:abc:room/pubkey", MoqLitePath.join("nests/30312:abc:room", "pubkey"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stripPrefix_is_path_component_aware() {
|
||||||
|
assertEquals("baz", MoqLitePath.stripPrefix("foo/bar", "foo/bar/baz"))
|
||||||
|
assertEquals("", MoqLitePath.stripPrefix("foo/bar", "foo/bar"))
|
||||||
|
// Prefix must end on a path-component boundary.
|
||||||
|
assertNull(MoqLitePath.stripPrefix("foo", "foobar"))
|
||||||
|
assertNull(MoqLitePath.stripPrefix("foo/bar", "foo/barbaz"))
|
||||||
|
// Disjoint paths return null.
|
||||||
|
assertNull(MoqLitePath.stripPrefix("foo/bar", "baz"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stripPrefix_normalizes_both_sides() {
|
||||||
|
assertEquals("baz", MoqLitePath.stripPrefix("/foo/bar/", "/foo//bar/baz/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stripPrefix_with_empty_prefix_returns_normalized_path() {
|
||||||
|
assertEquals("foo/bar", MoqLitePath.stripPrefix("", "/foo//bar/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user