// Binary MQTT packet parser tests. // Go reference: golang/nats-server/server/mqtt.go // CONNECT parsing — mqttParseConnect (~line 700) // PUBLISH parsing — mqttParsePublish (~line 1200) // SUBSCRIBE parsing — mqttParseSub (~line 1400) // Wildcard translation — mqttToNATSSubjectConversion (~line 2200) using System.Text; using NATS.Server.Mqtt; namespace NATS.Server.Tests.Mqtt; public class MqttBinaryParserTests { // ========================================================================= // Helpers — build well-formed CONNECT packet payloads // ========================================================================= /// /// Builds the payload bytes (everything after the fixed header) of an MQTT /// 3.1.1 CONNECT packet. /// private static byte[] BuildConnectPayload( string clientId, bool cleanSession = true, ushort keepAlive = 60, string? username = null, string? password = null, string? willTopic = null, byte[]? willMessage = null, byte willQoS = 0, bool willRetain = false) { using var ms = new System.IO.MemoryStream(); using var w = new System.IO.BinaryWriter(ms); // Protocol name "MQTT" WriteString(w, "MQTT"); // Protocol level 4 (MQTT 3.1.1) w.Write((byte)4); // Connect flags byte flags = 0; if (cleanSession) flags |= 0x02; if (willTopic != null) flags |= 0x04; flags |= (byte)((willQoS & 0x03) << 3); if (willRetain) flags |= 0x20; if (password != null) flags |= 0x40; if (username != null) flags |= 0x80; w.Write(flags); // Keep-alive (big-endian) WriteUInt16BE(w, keepAlive); // Payload fields WriteString(w, clientId); if (willTopic != null) { WriteString(w, willTopic); WriteBinaryField(w, willMessage ?? []); } if (username != null) WriteString(w, username); if (password != null) WriteString(w, password); return ms.ToArray(); } private static void WriteString(System.IO.BinaryWriter w, string value) { var bytes = Encoding.UTF8.GetBytes(value); WriteUInt16BE(w, (ushort)bytes.Length); w.Write(bytes); } private static void WriteBinaryField(System.IO.BinaryWriter w, byte[] data) { WriteUInt16BE(w, (ushort)data.Length); w.Write(data); } private static void WriteUInt16BE(System.IO.BinaryWriter w, ushort value) { w.Write((byte)(value >> 8)); w.Write((byte)(value & 0xFF)); } // ========================================================================= // 1. ParseConnect — valid packet // Go reference: server/mqtt.go mqttParseConnect ~line 700 // ========================================================================= [Fact] public void ParseConnect_ValidPacket_ReturnsConnectInfo() { // Go: mqttParseConnect — basic CONNECT with protocol name, level, and empty client ID var payload = BuildConnectPayload("test-client", cleanSession: true, keepAlive: 30); var info = MqttBinaryDecoder.ParseConnect(payload); info.ProtocolName.ShouldBe("MQTT"); info.ProtocolLevel.ShouldBe((byte)4); info.CleanSession.ShouldBeTrue(); info.KeepAlive.ShouldBe((ushort)30); info.ClientId.ShouldBe("test-client"); info.Username.ShouldBeNull(); info.Password.ShouldBeNull(); info.WillTopic.ShouldBeNull(); info.WillMessage.ShouldBeNull(); } // ========================================================================= // 2. ParseConnect — with credentials // Go reference: server/mqtt.go mqttParseConnect ~line 780 // ========================================================================= [Fact] public void ParseConnect_WithCredentials() { // Go: mqttParseConnect — username and password flags set in connect flags byte var payload = BuildConnectPayload( "cred-client", cleanSession: true, keepAlive: 60, username: "alice", password: "s3cr3t"); var info = MqttBinaryDecoder.ParseConnect(payload); info.ClientId.ShouldBe("cred-client"); info.Username.ShouldBe("alice"); info.Password.ShouldBe("s3cr3t"); } // ========================================================================= // 3. ParseConnect — with will message // Go reference: server/mqtt.go mqttParseConnect ~line 740 // ========================================================================= [Fact] public void ParseConnect_WithWillMessage() { // Go: mqttParseConnect — WillFlag + WillTopic + WillMessage in payload var willBytes = Encoding.UTF8.GetBytes("offline"); var payload = BuildConnectPayload( "will-client", willTopic: "status/device", willMessage: willBytes, willQoS: 1, willRetain: true); var info = MqttBinaryDecoder.ParseConnect(payload); info.ClientId.ShouldBe("will-client"); info.WillTopic.ShouldBe("status/device"); info.WillMessage.ShouldNotBeNull(); info.WillMessage!.ShouldBe(willBytes); info.WillQoS.ShouldBe((byte)1); info.WillRetain.ShouldBeTrue(); } // ========================================================================= // 4. ParseConnect — clean session flag // Go reference: server/mqtt.go mqttParseConnect ~line 710 // ========================================================================= [Fact] public void ParseConnect_CleanSessionFlag() { // Go: mqttParseConnect — clean session bit 1 of connect flags var withClean = BuildConnectPayload("c1", cleanSession: true); var withoutClean = BuildConnectPayload("c2", cleanSession: false); MqttBinaryDecoder.ParseConnect(withClean).CleanSession.ShouldBeTrue(); MqttBinaryDecoder.ParseConnect(withoutClean).CleanSession.ShouldBeFalse(); } // ========================================================================= // 5. ParsePublish — QoS 0 (no packet ID) // Go reference: server/mqtt.go mqttParsePublish ~line 1200 // ========================================================================= [Fact] public void ParsePublish_QoS0() { // Go: mqttParsePublish — QoS 0: no packet identifier present // Build payload: 2-byte length + "sensors/temp" + message bytes var topic = "sensors/temp"; var topicBytes = Encoding.UTF8.GetBytes(topic); var message = Encoding.UTF8.GetBytes("23.5"); using var ms = new System.IO.MemoryStream(); using var w = new System.IO.BinaryWriter(ms); WriteUInt16BE(w, (ushort)topicBytes.Length); w.Write(topicBytes); w.Write(message); var payload = ms.ToArray(); // flags = 0x00 → QoS 0, no DUP, no RETAIN var info = MqttBinaryDecoder.ParsePublish(payload, flags: 0x00); info.Topic.ShouldBe("sensors/temp"); info.QoS.ShouldBe((byte)0); info.PacketId.ShouldBe((ushort)0); info.Dup.ShouldBeFalse(); info.Retain.ShouldBeFalse(); Encoding.UTF8.GetString(info.Payload.Span).ShouldBe("23.5"); } // ========================================================================= // 6. ParsePublish — QoS 1 (has packet ID) // Go reference: server/mqtt.go mqttParsePublish ~line 1230 // ========================================================================= [Fact] public void ParsePublish_QoS1() { // Go: mqttParsePublish — QoS 1: 2-byte packet identifier follows topic var topic = "events/click"; var topicBytes = Encoding.UTF8.GetBytes(topic); var message = Encoding.UTF8.GetBytes("payload-data"); using var ms = new System.IO.MemoryStream(); using var w = new System.IO.BinaryWriter(ms); WriteUInt16BE(w, (ushort)topicBytes.Length); w.Write(topicBytes); WriteUInt16BE(w, 42); // packet ID = 42 w.Write(message); var payload = ms.ToArray(); // flags = 0x02 → QoS 1 (bits 2-1 = 01) var info = MqttBinaryDecoder.ParsePublish(payload, flags: 0x02); info.Topic.ShouldBe("events/click"); info.QoS.ShouldBe((byte)1); info.PacketId.ShouldBe((ushort)42); Encoding.UTF8.GetString(info.Payload.Span).ShouldBe("payload-data"); } // ========================================================================= // 7. ParsePublish — retain flag // Go reference: server/mqtt.go mqttParsePublish ~line 1210 // ========================================================================= [Fact] public void ParsePublish_RetainFlag() { // Go: mqttParsePublish — RETAIN flag is bit 0 of the fixed-header flags nibble var topicBytes = Encoding.UTF8.GetBytes("home/light"); using var ms = new System.IO.MemoryStream(); using var w = new System.IO.BinaryWriter(ms); WriteUInt16BE(w, (ushort)topicBytes.Length); w.Write(topicBytes); w.Write(Encoding.UTF8.GetBytes("on")); var payload = ms.ToArray(); // flags = 0x01 → RETAIN set, QoS 0 var info = MqttBinaryDecoder.ParsePublish(payload, flags: 0x01); info.Topic.ShouldBe("home/light"); info.Retain.ShouldBeTrue(); info.QoS.ShouldBe((byte)0); } // ========================================================================= // 8. ParseSubscribe — single topic // Go reference: server/mqtt.go mqttParseSub ~line 1400 // ========================================================================= [Fact] public void ParseSubscribe_SingleTopic() { // Go: mqttParseSub — SUBSCRIBE with a single topic filter entry // Payload: 2-byte packet-id + (2-byte len + topic + 1-byte QoS) per entry using var ms = new System.IO.MemoryStream(); using var w = new System.IO.BinaryWriter(ms); WriteUInt16BE(w, 7); // packet ID = 7 WriteString(w, "sport/tennis/#"); // topic filter w.Write((byte)0); // QoS 0 var payload = ms.ToArray(); var info = MqttBinaryDecoder.ParseSubscribe(payload); info.PacketId.ShouldBe((ushort)7); info.Filters.Count.ShouldBe(1); info.Filters[0].TopicFilter.ShouldBe("sport/tennis/#"); info.Filters[0].QoS.ShouldBe((byte)0); } // ========================================================================= // 9. ParseSubscribe — multiple topics with different QoS // Go reference: server/mqtt.go mqttParseSub ~line 1420 // ========================================================================= [Fact] public void ParseSubscribe_MultipleTopics() { // Go: mqttParseSub — multiple topic filter entries in one SUBSCRIBE using var ms = new System.IO.MemoryStream(); using var w = new System.IO.BinaryWriter(ms); WriteUInt16BE(w, 99); // packet ID = 99 WriteString(w, "sensors/+"); // filter 1 w.Write((byte)0); // QoS 0 WriteString(w, "events/#"); // filter 2 w.Write((byte)1); // QoS 1 WriteString(w, "alerts/critical"); // filter 3 w.Write((byte)2); // QoS 2 var payload = ms.ToArray(); var info = MqttBinaryDecoder.ParseSubscribe(payload); info.PacketId.ShouldBe((ushort)99); info.Filters.Count.ShouldBe(3); info.Filters[0].TopicFilter.ShouldBe("sensors/+"); info.Filters[0].QoS.ShouldBe((byte)0); info.Filters[1].TopicFilter.ShouldBe("events/#"); info.Filters[1].QoS.ShouldBe((byte)1); info.Filters[2].TopicFilter.ShouldBe("alerts/critical"); info.Filters[2].QoS.ShouldBe((byte)2); } // ========================================================================= // 10. TranslateWildcard — '+' → '*' // Go reference: server/mqtt.go mqttToNATSSubjectConversion ~line 2200 // ========================================================================= [Fact] public void TranslateWildcard_Plus() { // Go: mqttToNATSSubjectConversion — '+' maps to '*' (single-level) var result = MqttBinaryDecoder.TranslateFilterToNatsSubject("+"); result.ShouldBe("*"); } // ========================================================================= // 11. TranslateWildcard — '#' → '>' // Go reference: server/mqtt.go mqttToNATSSubjectConversion ~line 2210 // ========================================================================= [Fact] public void TranslateWildcard_Hash() { // Go: mqttToNATSSubjectConversion — '#' maps to '>' (multi-level) var result = MqttBinaryDecoder.TranslateFilterToNatsSubject("#"); result.ShouldBe(">"); } // ========================================================================= // 12. TranslateWildcard — '/' → '.' // Go reference: server/mqtt.go mqttToNATSSubjectConversion ~line 2220 // ========================================================================= [Fact] public void TranslateWildcard_Slash() { // Go: mqttToNATSSubjectConversion — '/' separator maps to '.' var result = MqttBinaryDecoder.TranslateFilterToNatsSubject("a/b/c"); result.ShouldBe("a.b.c"); } // ========================================================================= // 13. TranslateWildcard — complex combined translation // Go reference: server/mqtt.go mqttToNATSSubjectConversion ~line 2200 // ========================================================================= [Fact] public void TranslateWildcard_Complex() { // Go: mqttToNATSSubjectConversion — combines '/', '+', '#' // sport/+/score/# → sport.*.score.> var result = MqttBinaryDecoder.TranslateFilterToNatsSubject("sport/+/score/#"); result.ShouldBe("sport.*.score.>"); } // ========================================================================= // 14. DecodeRemainingLength — multi-byte values (VarInt edge cases) // Go reference: server/mqtt.go TestMQTTReader / TestMQTTWriter // ========================================================================= [Theory] [InlineData(new byte[] { 0x00 }, 0, 1)] [InlineData(new byte[] { 0x01 }, 1, 1)] [InlineData(new byte[] { 0x7F }, 127, 1)] [InlineData(new byte[] { 0x80, 0x01 }, 128, 2)] [InlineData(new byte[] { 0xFF, 0x7F }, 16383, 2)] [InlineData(new byte[] { 0x80, 0x80, 0x01 }, 16384, 3)] [InlineData(new byte[] { 0xFF, 0xFF, 0x7F }, 2097151, 3)] [InlineData(new byte[] { 0x80, 0x80, 0x80, 0x01 }, 2097152, 4)] [InlineData(new byte[] { 0xFF, 0xFF, 0xFF, 0x7F }, 268435455, 4)] public void DecodeRemainingLength_MultiByteValues(byte[] encoded, int expectedValue, int expectedConsumed) { // Go TestMQTTReader: verifies variable-length integer decoding at all boundary values var value = MqttPacketReader.DecodeRemainingLength(encoded, out var consumed); value.ShouldBe(expectedValue); consumed.ShouldBe(expectedConsumed); } // ========================================================================= // Additional edge-case tests // ========================================================================= [Fact] public void ParsePublish_DupFlag_IsSet() { // DUP flag is bit 3 of the fixed-header flags nibble (0x08). // When QoS > 0, a 2-byte packet identifier must follow the topic. var topicBytes = Encoding.UTF8.GetBytes("dup/topic"); using var ms = new System.IO.MemoryStream(); using var w = new System.IO.BinaryWriter(ms); WriteUInt16BE(w, (ushort)topicBytes.Length); w.Write(topicBytes); WriteUInt16BE(w, 5); // packet ID = 5 (required for QoS 1) var payload = ms.ToArray(); // flags = 0x0A → DUP (bit 3) + QoS 1 (bits 2-1) var info = MqttBinaryDecoder.ParsePublish(payload, flags: 0x0A); info.Dup.ShouldBeTrue(); info.QoS.ShouldBe((byte)1); info.PacketId.ShouldBe((ushort)5); } [Fact] public void ParseConnect_EmptyClientId_IsAllowed() { // MQTT 3.1.1 §3.1.3.1 allows empty client IDs with CleanSession=true var payload = BuildConnectPayload("", cleanSession: true); var info = MqttBinaryDecoder.ParseConnect(payload); info.ClientId.ShouldBe(string.Empty); info.CleanSession.ShouldBeTrue(); } [Fact] public void TranslateWildcard_EmptyString_ReturnsEmpty() { var result = MqttBinaryDecoder.TranslateFilterToNatsSubject(string.Empty); result.ShouldBe(string.Empty); } [Fact] public void TranslateWildcard_PlainTopic_NoChange() { // A topic with no wildcards or slashes should pass through unchanged var result = MqttBinaryDecoder.TranslateFilterToNatsSubject("plainword"); result.ShouldBe("plainword"); } [Fact] public void ParsePublish_EmptyPayload_IsAllowed() { // A PUBLISH with no application payload is valid (e.g. retain-delete) var topicBytes = Encoding.UTF8.GetBytes("empty/payload"); using var ms = new System.IO.MemoryStream(); using var w = new System.IO.BinaryWriter(ms); WriteUInt16BE(w, (ushort)topicBytes.Length); w.Write(topicBytes); var payload = ms.ToArray(); var info = MqttBinaryDecoder.ParsePublish(payload, flags: 0x00); info.Topic.ShouldBe("empty/payload"); info.Payload.Length.ShouldBe(0); } }