Skip to content

Commit a1dadc4

Browse files
jamesarichCopilot
andauthored
fix: gracefully decode v3.1.1 packets in v5 sessions (mixed broker compat) (#40)
* fix: gracefully decode v3.1.1 packets in v5 sessions (mixed broker compat) Some brokers (e.g. mqtt.meshtastic.pt) accept an MQTT 5.0 CONNECT but respond with 3.1.1-style packets (no properties section in CONNACK, SUBACK, UNSUBACK, PUBLISH, PUBACK, etc.). This caused a crash in the properties decoder: 'Not enough bytes for boolean at offset 4' or 'Unknown property ID: 0x85', killing the read loop and triggering an infinite reconnect cycle. Fix: introduce tryDecodePropertiesSection() — a try/catch wrapper around the strict decoder that returns null on IllegalArgumentException. Applied in decodePublish, decodePubAckLike (size >= 4), decodeSubAck, and decodeUnsubAck so that when v5 properties parsing fails, the decoder falls back to treating the bytes as 3.1.1 (no properties, bare reason codes / raw payload). This mirrors HiveMQ's approach for CONNACK (tryDecodeMqtt3) but extends it to all response packet types for full compatibility with misconfigured brokers that advertise v5 but speak v3.1.1. Also adds bounds checks and hex-dump diagnostics to the properties decoder for easier debugging of future protocol issues. Tested successfully against mqtt.meshtastic.pt:1883 — 10/10 publishes succeed, incoming mesh traffic decoded correctly, zero reconnects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: bump version to 0.3.6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 81bb29a commit a1dadc4

6 files changed

Lines changed: 597 additions & 19 deletions

File tree

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ android.nonTransitiveRClass=true
1616
#Picked up automatically by com.vanniktech.maven.publish. Override in CI with -PVERSION_NAME=...
1717
GROUP=org.meshtastic
1818
POM_ARTIFACT_ID=mqtt-client
19-
VERSION_NAME=0.3.5
19+
VERSION_NAME=0.3.6
2020
#Compose Desktop — allow Homebrew/OpenJDK vendors for local `packageReleaseDmg` runs.
2121
#CI uses Temurin via setup-java so this is a no-op there.
2222
compose.desktop.packaging.checkJdkVendor=false

library/src/commonMain/kotlin/org/meshtastic/mqtt/MqttConnection.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import org.meshtastic.mqtt.packet.UnsubAck
5858
import org.meshtastic.mqtt.packet.Unsubscribe
5959
import org.meshtastic.mqtt.packet.decodePacket
6060
import org.meshtastic.mqtt.packet.encode
61+
import org.meshtastic.mqtt.packet.hexDump
6162
import kotlin.concurrent.Volatile
6263
import kotlin.time.TimeMark
6364
import kotlin.time.TimeSource
@@ -830,7 +831,11 @@ internal class MqttConnection(
830831
try {
831832
decodePacket(bytes, version)
832833
} catch (e: IllegalArgumentException) {
833-
log.error(TAG) { "Failed to decode packet: ${e.message}" }
834+
log.error(TAG) {
835+
"Failed to decode packet (${bytes.size} bytes, " +
836+
"version=$version): ${e.message}. " +
837+
"Raw hex: ${bytes.hexDump()}"
838+
}
834839
throw e
835840
}
836841
log.debug(TAG) { "Received ${packet.packetType}" }

library/src/commonMain/kotlin/org/meshtastic/mqtt/packet/MqttDecoder.kt

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,16 @@ private fun decodePublish(
250250

251251
val properties: MqttProperties
252252
if (version.supportsProperties) {
253-
val (props, propsConsumed) = decodePropertiesSection(body, pos)
254-
properties = props
255-
pos += propsConsumed
253+
// Some brokers accept a v5 CONNECT but relay messages from 3.1.1 clients
254+
// without a properties section. Try v5 decode; fall back to treating
255+
// remaining bytes as raw payload (3.1.1 style).
256+
val propertiesResult = tryDecodePropertiesSection(body, pos)
257+
if (propertiesResult != null) {
258+
properties = propertiesResult.first
259+
pos += propertiesResult.second
260+
} else {
261+
properties = MqttProperties.EMPTY
262+
}
256263
} else {
257264
properties = MqttProperties.EMPTY
258265
}
@@ -297,7 +304,7 @@ private fun <T : MqttPacket> decodePubAckLike(
297304
return factory(packetId, reasonCode, MqttProperties.EMPTY)
298305
}
299306

300-
val (properties, _) = decodePropertiesSection(body, 3)
307+
val properties = tryDecodePropertiesSection(body, 3)?.first ?: MqttProperties.EMPTY
301308
return factory(packetId, reasonCode, properties)
302309
}
303310

@@ -376,9 +383,16 @@ private fun decodeSubAck(
376383

377384
val properties: MqttProperties
378385
if (version.supportsProperties) {
379-
val (props, propsConsumed) = decodePropertiesSection(body, pos)
380-
properties = props
381-
pos += propsConsumed
386+
// Some brokers accept a v5 CONNECT but respond with 3.1.1-style SUBACKs
387+
// (no properties section). Detect this by trying v5 decode first; if properties
388+
// parsing fails, fall back to treating remaining bytes as bare return codes.
389+
val propertiesResult = tryDecodePropertiesSection(body, pos)
390+
if (propertiesResult != null) {
391+
properties = propertiesResult.first
392+
pos += propertiesResult.second
393+
} else {
394+
properties = MqttProperties.EMPTY
395+
}
382396
} else {
383397
properties = MqttProperties.EMPTY
384398
}
@@ -461,8 +475,16 @@ private fun decodeUnsubAck(
461475
)
462476
}
463477

464-
val (properties, propsConsumed) = decodePropertiesSection(body, pos)
465-
pos += propsConsumed
478+
// Some brokers accept a v5 CONNECT but respond with 3.1.1-style packets.
479+
// Try v5 properties decode; fall back to bare reason codes on failure.
480+
val propertiesResult = tryDecodePropertiesSection(body, pos)
481+
val properties: MqttProperties
482+
if (propertiesResult != null) {
483+
properties = propertiesResult.first
484+
pos += propertiesResult.second
485+
} else {
486+
properties = MqttProperties.EMPTY
487+
}
466488

467489
val reasonCodes = mutableListOf<ReasonCode>()
468490
while (pos < body.size) {
@@ -548,7 +570,25 @@ private fun decodePropertiesSection(
548570
return props to (lengthResult.bytesConsumed + propsLength)
549571
}
550572

551-
private fun ByteArray.toHexDump(): String {
573+
/**
574+
* Try to decode a properties section. Returns null if the bytes don't form a valid
575+
* properties section — this handles brokers that accept a v5 CONNECT but respond with
576+
* 3.1.1-style packets (no properties section in SUBACK/UNSUBACK/etc.).
577+
*/
578+
@Suppress("SwallowedException")
579+
private fun tryDecodePropertiesSection(
580+
bytes: ByteArray,
581+
offset: Int,
582+
): Pair<MqttProperties, Int>? =
583+
try {
584+
decodePropertiesSection(bytes, offset)
585+
} catch (_: IllegalArgumentException) {
586+
null
587+
}
588+
589+
private fun ByteArray.toHexDump(): String = hexDump()
590+
591+
internal fun ByteArray.hexDump(): String {
552592
fun Byte.hex(): String {
553593
val i = toInt() and 0xFF
554594
val hi = "0123456789abcdef"[i ushr 4]

library/src/commonMain/kotlin/org/meshtastic/mqtt/packet/MqttProperties.kt

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,10 +242,20 @@ internal fun decodeProperties(
242242
var sharedSubscriptionAvailable: Boolean? = null
243243

244244
while (pos < end) {
245+
require(pos < bytes.size) {
246+
"Property section overflows buffer: pos=$pos, end=$end, " +
247+
"bodySize=${bytes.size}. Hex: ${bytes.hexDump()}"
248+
}
245249
val idResult = VariableByteInt.decode(bytes, pos)
246250
pos += idResult.bytesConsumed
247251
val propertyId = idResult.value
248252

253+
// Bounds-check: the property value must start within the declared section
254+
require(pos <= end && pos <= bytes.size) {
255+
"Property 0x${propertyId.toString(16)} value overflows section: " +
256+
"pos=$pos, end=$end, bodySize=${bytes.size}. Hex: ${bytes.hexDump()}"
257+
}
258+
249259
// Multi-occurrence properties: User Property and Subscription Identifier
250260
val isMultiOccurrence =
251261
propertyId == PropertyId.USER_PROPERTY ||
@@ -445,9 +455,15 @@ private fun decodeBooleanByte(
445455
bytes: ByteArray,
446456
offset: Int,
447457
): Boolean {
448-
require(offset < bytes.size) { "Not enough bytes for boolean at offset $offset" }
458+
require(offset < bytes.size) {
459+
"Not enough bytes for boolean at offset $offset " +
460+
"(bodySize=${bytes.size}). Hex: ${bytes.hexDump()}"
461+
}
449462
val b = bytes[offset].toInt() and 0xFF
450-
require(b == 0 || b == 1) { "Malformed boolean value 0x${b.toString(16)} at offset $offset (§1.5.1.4)" }
463+
require(b == 0 || b == 1) {
464+
"Malformed boolean value 0x${b.toString(16)} at offset $offset (§1.5.1.4). " +
465+
"Hex: ${bytes.hexDump()}"
466+
}
451467
return b == 1
452468
}
453469

library/src/commonTest/kotlin/org/meshtastic/mqtt/MqttConnectionTest.kt

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,7 +1145,7 @@ class MqttConnectionTest {
11451145
}
11461146

11471147
@Test
1148-
fun v5ConnectionDisconnectsOnMalformedPacket() =
1148+
fun v5ConnectionDecodesV311PublishGracefully() =
11491149
runTest {
11501150
val transport = FakeTransport()
11511151
val connection = MqttConnection(transport, defaultConfig(), this)
@@ -1154,8 +1154,9 @@ class MqttConnectionTest {
11541154
connection.connect(endpoint)
11551155
advanceUntilIdle()
11561156

1157-
// Send a v3.1.1-encoded packet to a v5 connection — should be treated as
1158-
// a decode error and disconnect, not silently downgrade protocol version.
1157+
// Some brokers accept a v5 CONNECT but relay PUBLISH messages from
1158+
// v3.1.1 clients without a properties section. The decoder should
1159+
// fall back gracefully (empty properties) instead of disconnecting.
11591160
val v311Publish =
11601161
Publish(
11611162
topicName = "test/topic",
@@ -1166,10 +1167,13 @@ class MqttConnectionTest {
11661167
advanceUntilIdle()
11671168

11681169
val state = connection.connectionState.value
1169-
assertIs<ConnectionState.Disconnected>(
1170+
assertIs<ConnectionState.Connected>(
11701171
state,
1171-
"Connection should disconnect on malformed packet, not downgrade protocol",
1172+
"Connection should stay connected when receiving a v3.1.1 PUBLISH in a v5 session",
11721173
)
1174+
1175+
connection.disconnect()
1176+
advanceUntilIdle()
11731177
}
11741178

11751179
// --- Session Present validation (§3.2.2.1.1) ---

0 commit comments

Comments
 (0)