Skip to content

MqttCodec.kt

src/jvmMain/kotlin/afield/io/mqtt/MqttCodec.kt · 373 lines

@file:afield.runtime.completion.CompletionCategory("Networking")

package afield.io.mqtt

import java.io.ByteArrayOutputStream
import java.io.EOFException
import java.io.IOException
import java.io.InputStream

The MQTT 3.1.1 wire format: the MqttPacket types, Mqtt.encode and Mqtt.read to convert them to and from bytes, and MqttTopic for topic names and + / # filter matching.

The client and the embedded broker both use this codec. MqttCodecTest and MqttTopicSubsumesTest cover it.

class MqttProtocolException(message: String) : IOException(message)

A malformed MQTT packet, or one this implementation refuses.

enum class QoS(val value: Int)

Delivery guarantee of a publish or a subscription. QoS 2 is not implemented; see Mqtt.

enum class QoS(val value: Int) {

Fire and forget.

    AT_MOST_ONCE(0),

Acknowledged with PUBACK, re-sent until it is; may arrive twice.

    AT_LEAST_ONCE(1),

Four-step handshake. Accepted on the wire, granted as AT_LEAST_ONCE.

    EXACTLY_ONCE(2);

companion object

fun of(v: Int): QoS

        fun of(v: Int): QoS = entries.firstOrNull { it.value == v } ?: throw MqttProtocolException("bad QoS $v")
    }
}

sealed class MqttPacket

An MQTT 3.1.1 control packet. Mqtt.encode and Mqtt.read convert to and from bytes.

data class Will(val topic: String, val payload: ByteArray, val qos: QoS = QoS.AT_MOST_ONCE, val retain: Boolean = false)

override fun equals(other: Any?)

        override fun equals(other: Any?) = other is Will && topic == other.topic && payload.contentEquals(other.payload) && qos == other.qos && retain == other.retain

override fun hashCode() = topic.hashCode() * 31 + payload.contentHashCode()

data class Connect

    data class Connect(
        val clientId: String,
        val cleanSession: Boolean = true,
        val keepAliveSeconds: Int = 60,
        val will: Will? = null,
        val username: String? = null,
        val password: ByteArray? = null,
        val protocolLevel: Int = 4,
    ) : MqttPacket() {

override fun equals(other: Any?)

        override fun equals(other: Any?) = other is Connect && clientId == other.clientId && cleanSession == other.cleanSession &&
            keepAliveSeconds == other.keepAliveSeconds && will == other.will && username == other.username &&
            (password?.toList() == other.password?.toList()) && protocolLevel == other.protocolLevel

override fun hashCode() = clientId.hashCode()

data class ConnAck(val sessionPresent: Boolean, val returnCode: Int) : MqttPacket()

returnCode 0 is accepted; 1–5 are the 3.1.1 refusals (see Mqtt.connackReason).

class Publish

    class Publish(
        val topic: String,
        val payload: ByteArray,
        val qos: QoS = QoS.AT_MOST_ONCE,
        val retain: Boolean = false,
        val dup: Boolean = false,
        /** Present only for QoS > 0. */
        val packetId: Int = 0,
    ) : MqttPacket() {

val text: String get() = payload.decodeToString()

fun copy(qos: QoS = this.qos, retain: Boolean = this.retain, dup: Boolean = this.dup, packetId: Int = this.packetId)

        fun copy(qos: QoS = this.qos, retain: Boolean = this.retain, dup: Boolean = this.dup, packetId: Int = this.packetId) =
            Publish(topic, payload, qos, retain, dup, packetId)

override fun equals(other: Any?)

        override fun equals(other: Any?) = other is Publish && topic == other.topic && payload.contentEquals(other.payload) &&
            qos == other.qos && retain == other.retain && dup == other.dup && packetId == other.packetId

override fun hashCode() = topic.hashCode() * 31 + packetId

override fun toString()

        override fun toString() = "Publish($topic, ${payload.size} bytes, $qos, retain=$retain, dup=$dup, id=$packetId)"
    }

data class PubAck(val packetId: Int) : MqttPacket()

data class PubRec(val packetId: Int) : MqttPacket()

data class PubRel(val packetId: Int) : MqttPacket()

data class PubComp(val packetId: Int) : MqttPacket()

data class Subscribe(val packetId: Int, val filters: List<Pair<String, QoS>>) : MqttPacket()

data class SubAck(val packetId: Int, val returnCodes: List<Int>) : MqttPacket()

One return code per filter: the granted QoS, or 0x80 for failure.

data class Unsubscribe(val packetId: Int, val filters: List<String>) : MqttPacket()

data class UnsubAck(val packetId: Int) : MqttPacket()

data object PingReq : MqttPacket()

data object PingResp : MqttPacket()

data object Disconnect : MqttPacket()

object Mqtt

The MQTT 3.1.1 wire format (OASIS standard, 2014): fixed header, remaining-length varint, length-prefixed UTF-8 strings, and every control packet.

const val PORT = 1883

const val MAX_REMAINING_LENGTH = 268_435_455

Largest remaining length the 4-byte varint can express (§2.2.3).

fun connackReason(code: Int): String

    fun connackReason(code: Int): String = when (code) {
        0 -> "accepted"
        1 -> "unacceptable protocol version"
        2 -> "identifier rejected"
        3 -> "server unavailable"
        4 -> "bad user name or password"
        5 -> "not authorized"
        else -> "unknown return code $code"
    }

fun encodeRemainingLength(length: Int, out: ByteArrayOutputStream)

§2.2.3: seven bits per byte, least significant group first, top bit = more follows.

    fun encodeRemainingLength(length: Int, out: ByteArrayOutputStream) {
        require(length in 0..MAX_REMAINING_LENGTH) { "remaining length $length out of range" }
        var x = length
        do {
            var b = x % 128
            x /= 128
            if (x > 0) b = b or 0x80
            out.write(b)
        } while (x > 0)
    }

fun remainingLengthBytes(length: Int): ByteArray

    fun remainingLengthBytes(length: Int): ByteArray = ByteArrayOutputStream().also { encodeRemainingLength(length, it) }.toByteArray()

fun readRemainingLength(input: InputStream): Int

Reads a remaining-length varint; refuses a fifth byte, as §2.2.3 requires.

    fun readRemainingLength(input: InputStream): Int {
        var multiplier = 1
        var value = 0
        for (i in 0 until 4) {
            val b = input.read()
            if (b < 0) throw EOFException("stream ended inside remaining length")
            value += (b and 0x7f) * multiplier
            if (b and 0x80 == 0) return value
            multiplier *= 128
        }
        throw MqttProtocolException("malformed remaining length (more than 4 bytes)")
    }

    private fun ByteArrayOutputStream.u16(v: Int) { write((v shr 8) and 0xff); write(v and 0xff) }
    private fun ByteArrayOutputStream.bin(b: ByteArray) {
        require(b.size <= 0xffff) { "field longer than 65535 bytes" }
        u16(b.size); write(b)
    }
    private fun ByteArrayOutputStream.str(s: String) = bin(s.encodeToByteArray())

    private fun frame(header: Int, body: ByteArray): ByteArray {
        val out = ByteArrayOutputStream(body.size + 5)
        out.write(header)
        encodeRemainingLength(body.size, out)
        out.write(body)
        return out.toByteArray()
    }

    private fun body(block: ByteArrayOutputStream.() -> Unit) = ByteArrayOutputStream().apply(block).toByteArray()

fun encode(p: MqttPacket): ByteArray

    fun encode(p: MqttPacket): ByteArray = when (p) {
        is MqttPacket.Connect -> frame(0x10, body {
            str(if (p.protocolLevel == 3) "MQIsdp" else "MQTT")
            write(p.protocolLevel)
            var flags = 0
            if (p.cleanSession) flags = flags or 0x02
            p.will?.let { flags = flags or 0x04 or (it.qos.value shl 3) or (if (it.retain) 0x20 else 0) }
            if (p.password != null) flags = flags or 0x40
            if (p.username != null) flags = flags or 0x80
            write(flags)
            u16(p.keepAliveSeconds)
            str(p.clientId)
            p.will?.let { str(it.topic); bin(it.payload) }
            p.username?.let { str(it) }
            p.password?.let { bin(it) }
        })
        is MqttPacket.ConnAck -> frame(0x20, byteArrayOf(if (p.sessionPresent) 1 else 0, p.returnCode.toByte()))
        is MqttPacket.Publish -> {
            val header = 0x30 or (if (p.dup) 0x08 else 0) or (p.qos.value shl 1) or (if (p.retain) 1 else 0)
            frame(header, body {
                str(p.topic)
                if (p.qos != QoS.AT_MOST_ONCE) u16(p.packetId)
                write(p.payload)
            })
        }
        is MqttPacket.PubAck -> frame(0x40, body { u16(p.packetId) })
        is MqttPacket.PubRec -> frame(0x50, body { u16(p.packetId) })
        is MqttPacket.PubRel -> frame(0x62, body { u16(p.packetId) })
        is MqttPacket.PubComp -> frame(0x70, body { u16(p.packetId) })
        is MqttPacket.Subscribe -> frame(0x82, body {
            u16(p.packetId)
            p.filters.forEach { (f, q) -> str(f); write(q.value) }
        })
        is MqttPacket.SubAck -> frame(0x90, body { u16(p.packetId); p.returnCodes.forEach { write(it) } })
        is MqttPacket.Unsubscribe -> frame(0xA2, body { u16(p.packetId); p.filters.forEach { str(it) } })
        is MqttPacket.UnsubAck -> frame(0xB0, body { u16(p.packetId) })
        MqttPacket.PingReq -> byteArrayOf(0xC0.toByte(), 0)
        MqttPacket.PingResp -> byteArrayOf(0xD0.toByte(), 0)
        MqttPacket.Disconnect -> byteArrayOf(0xE0.toByte(), 0)
    }

fun read(input: InputStream, maxPacketSize: Int = MAX_REMAINING_LENGTH): MqttPacket?

Reads one packet; null on a clean end of stream between packets.

    fun read(input: InputStream, maxPacketSize: Int = MAX_REMAINING_LENGTH): MqttPacket? {
        val header = input.read()
        if (header < 0) return null
        val length = readRemainingLength(input)
        if (length > maxPacketSize) throw MqttProtocolException("packet of $length bytes exceeds limit $maxPacketSize")
        val body = input.readNBytes(length)
        if (body.size < length) throw EOFException("stream ended inside packet")
        return decode(header, body)
    }

fun decode(bytes: ByteArray): MqttPacket

Decodes a whole packet held in bytes (header, length and body).

    fun decode(bytes: ByteArray): MqttPacket = read(bytes.inputStream()) ?: throw EOFException("empty")

    private class Reader(val b: ByteArray) {
        var i = 0
        val left get() = b.size - i
        fun u8(): Int { if (i >= b.size) throw MqttProtocolException("packet too short"); return b[i++].toInt() and 0xff }
        fun u16(): Int = (u8() shl 8) or u8()
        fun bin(): ByteArray {
            val n = u16()
            if (n > left) throw MqttProtocolException("string runs past the packet")
            return b.copyOfRange(i, i + n).also { i += n }
        }
        fun str(): String {
            val bytes = bin()
            val s = bytes.decodeToString()
            // §1.5.3: no U+0000, and the bytes must be well-formed UTF-8 (decodeToString replaces bad input).
            if ('\u0000' in s || !s.encodeToByteArray().contentEquals(bytes)) throw MqttProtocolException("malformed UTF-8 string")
            return s
        }
        fun rest(): ByteArray = b.copyOfRange(i, b.size).also { i = b.size }
    }

    private fun expectFlags(header: Int, flags: Int, name: String) {
        if (header and 0x0f != flags) throw MqttProtocolException("$name with reserved flags ${header and 0x0f}")
    }

fun decode(header: Int, body: ByteArray): MqttPacket

body — 56 lines, from line 239
    fun decode(header: Int, body: ByteArray): MqttPacket {
        val r = Reader(body)
        return when (header shr 4) {
            1 -> {
                expectFlags(header, 0, "CONNECT")
                val name = r.str()
                val level = r.u8()
                if (!((name == "MQTT" && level == 4) || (name == "MQIsdp" && level == 3))) {
                    // Still decoded so a broker can answer CONNACK 1 rather than drop the socket.
                    return MqttPacket.Connect(clientId = "", protocolLevel = level)
                }
                val flags = r.u8()
                if (flags and 0x01 != 0) throw MqttProtocolException("CONNECT reserved flag set")
                val keepAlive = r.u16()
                val clientId = r.str()
                val will = if (flags and 0x04 != 0) {
                    val t = r.str(); val payload = r.bin()
                    MqttPacket.Will(t, payload, QoS.of((flags shr 3) and 3), flags and 0x20 != 0)
                } else null
                val user = if (flags and 0x80 != 0) r.str() else null
                val pass = if (flags and 0x40 != 0) r.bin() else null
                MqttPacket.Connect(clientId, flags and 0x02 != 0, keepAlive, will, user, pass, level)
            }
            2 -> MqttPacket.ConnAck(r.u8() and 1 == 1, r.u8())
            3 -> {
                val qos = QoS.of((header shr 1) and 3)
                val topic = r.str()
                if (!MqttTopic.isValidName(topic)) throw MqttProtocolException("bad topic name '$topic'")
                val id = if (qos != QoS.AT_MOST_ONCE) r.u16() else 0
                MqttPacket.Publish(topic, r.rest(), qos, header and 1 == 1, header and 0x08 != 0, id)
            }
            4 -> MqttPacket.PubAck(r.u16())
            5 -> MqttPacket.PubRec(r.u16())
            6 -> MqttPacket.PubRel(r.u16())
            7 -> MqttPacket.PubComp(r.u16())
            8 -> {
                expectFlags(header, 2, "SUBSCRIBE")
                val id = r.u16()
                val filters = buildList { while (r.left > 0) add(r.str() to QoS.of(r.u8() and 3)) }
                if (filters.isEmpty()) throw MqttProtocolException("SUBSCRIBE with no filters")
                MqttPacket.Subscribe(id, filters)
            }
            9 -> { val id = r.u16(); MqttPacket.SubAck(id, buildList { while (r.left > 0) add(r.u8()) }) }
            10 -> {
                expectFlags(header, 2, "UNSUBSCRIBE")
                val id = r.u16()
                MqttPacket.Unsubscribe(id, buildList { while (r.left > 0) add(r.str()) })
            }
            11 -> MqttPacket.UnsubAck(r.u16())
            12 -> MqttPacket.PingReq
            13 -> MqttPacket.PingResp
            14 -> MqttPacket.Disconnect
            else -> throw MqttProtocolException("unknown packet type ${header shr 4}")
        }
    }
}

object MqttTopic

Topic names, topic filters and the + / # wildcards (MQTT 3.1.1 §4.7).

object MqttTopic {

fun isValidName(topic: String): Boolean = topic.isNotEmpty() && '+' !in topic && '#' !in topic

A name you can publish to: non-empty, no wildcards.

fun isValidFilter(filter: String): Boolean

A filter you can subscribe to: + fills a whole level, # only as the whole last level.

    fun isValidFilter(filter: String): Boolean {
        if (filter.isEmpty()) return false
        val levels = filter.split('/')
        levels.forEachIndexed { i, level ->
            if ('#' in level && (level != "#" || i != levels.lastIndex)) return false
            if ('+' in level && level != "+") return false
        }
        return true
    }

fun subsumes(f: String, g: String): Boolean

Whether every topic g matches is also matched by f (both valid filters). a/# subsumes a, a/+ and a/b/c; a/+ subsumes a/b; # and +/# subsume each other; every filter subsumes itself. A filter whose first level is a wildcard never subsumes one whose first level is a literal $-level, since leading wildcards do not match $ topics (§4.7.2).

    fun subsumes(f: String, g: String): Boolean {
        val a = f.split('/')
        val b = g.split('/')
        if ((a[0] == "+" || a[0] == "#") && b[0].startsWith("$")) return false
        var i = 0
        while (true) {
            // g ends here, so it matches topics of exactly this depth; f must match them too: f ends
            // here as well, or its only remaining level is `#`, which also matches the parent.
            if (i == b.size) return a.size == b.size || (a.size == b.size + 1 && a[i] == "#")
            if (i == a.size) return false
            val x = a[i]
            val y = b[i]
            when {
                x == "#" -> return true
                // g's `#` matches any depth from here, including the parent level (depth i). Only
                // `+/#` at the first level covers that, because a topic of depth 0 does not exist.
                y == "#" -> return i == 0 && x == "+" && a.size == 2 && a[1] == "#"
                x == "+" -> {}
                y == "+" -> return false
                x != y -> return false
            }
            i++
        }
    }

fun cover(filters: Map<String, QoS>): Map<String, QoS>

The smallest set of filters that matches every topic filters match: each filter subsumed by another is dropped (of two equivalent ones, the lexicographically smaller is kept), and each kept filter takes the highest QoS among the filters it covers.

    fun cover(filters: Map<String, QoS>): Map<String, QoS> {
        val keys = filters.keys
        val kept = keys.filter { f -> keys.none { g -> g != f && subsumes(g, f) && (!subsumes(f, g) || g < f) } }
        return kept.associateWith { k -> QoS.of(filters.filterKeys { subsumes(k, it) }.values.maxOf { it.value }) }
    }

fun matches(filter: String, topic: String): Boolean

Whether topic matches filter. sport/# matches sport itself; + matches an empty level; a filter starting with a wildcard does not match a topic starting with $ (§4.7.2).

    fun matches(filter: String, topic: String): Boolean {
        if (topic.startsWith("$") && (filter.startsWith("+") || filter.startsWith("#"))) return false
        val f = filter.split('/')
        val t = topic.split('/')
        var i = 0
        while (i < f.size) {
            val level = f[i]
            if (level == "#") return true
            if (i >= t.size) return false
            if (level != "+" && level != t[i]) return false
            i++
        }
        return i == t.size
    }
}