From f2b1928a948f12c52a576d69898307760740d453 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 1 Mar 2026 01:08:31 -0500 Subject: [PATCH] batch41: implement mqtt reader/writer and subject conversion slice --- .../ZB.MOM.NatsNet.Server/Mqtt/MqttReader.cs | 131 +++++++++++++++++ .../Mqtt/MqttSubjectConverter.cs | 137 ++++++++++++++++++ .../ZB.MOM.NatsNet.Server/Mqtt/MqttWriter.cs | 116 +++++++++++++++ .../ImplBacklog/MqttHandlerTests.Impltests.cs | 82 +++++++++++ porting.db | Bin 6758400 -> 6770688 bytes 5 files changed, 466 insertions(+) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttReader.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttSubjectConverter.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttWriter.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MqttHandlerTests.Impltests.cs diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttReader.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttReader.cs new file mode 100644 index 0000000..3bac6d4 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttReader.cs @@ -0,0 +1,131 @@ +// Copyright 2020-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 + +using System.Buffers.Binary; + +namespace ZB.MOM.NatsNet.Server.Mqtt; + +internal sealed class MqttReader +{ + private byte[] _buffer = []; + private byte[] _pendingBuffer = []; + + public int Position { get; private set; } + + public int PacketStart { get; set; } + + public void Reset(byte[] buffer) + { + if (_pendingBuffer.Length > 0) + { + var merged = new byte[_pendingBuffer.Length + buffer.Length]; + Buffer.BlockCopy(_pendingBuffer, 0, merged, 0, _pendingBuffer.Length); + Buffer.BlockCopy(buffer, 0, merged, _pendingBuffer.Length, buffer.Length); + buffer = merged; + _pendingBuffer = []; + } + + _buffer = buffer; + Position = 0; + PacketStart = 0; + } + + public bool HasMore() + { + return Position < _buffer.Length; + } + + public byte ReadByte(string field) + { + if (Position >= _buffer.Length) + { + throw new InvalidOperationException($"error reading {field}: {nameof(EndOfStreamException)}"); + } + + return _buffer[Position++]; + } + + public (int Length, bool Complete) ReadPacketLen() + { + return ReadPacketLenWithCheck(check: true); + } + + public (int Length, bool Complete) ReadPacketLenWithCheck(bool check) + { + var multiplier = 1; + var value = 0; + + while (Position < _buffer.Length) + { + var b = _buffer[Position++]; + value += (b & 0x7F) * multiplier; + + if ((b & 0x80) == 0) + { + if (check && Position + value > _buffer.Length) + { + break; + } + + return (value, true); + } + + multiplier *= 0x80; + if (multiplier > 0x200000) + { + throw new InvalidOperationException("malformed MQTT variable integer"); + } + } + + _pendingBuffer = _buffer[PacketStart..]; + return (0, false); + } + + public string ReadString(string field) + { + var data = ReadBytes(field, copy: false); + return data.Length == 0 ? string.Empty : System.Text.Encoding.UTF8.GetString(data); + } + + public byte[] ReadBytes(string field, bool copy) + { + var length = ReadUInt16(field); + if (length == 0) + { + return []; + } + + if (Position + length > _buffer.Length) + { + throw new InvalidOperationException($"error reading {field}: {nameof(EndOfStreamException)}"); + } + + var start = Position; + Position += length; + + if (!copy) + { + return _buffer[start..Position]; + } + + var result = new byte[length]; + Buffer.BlockCopy(_buffer, start, result, 0, length); + return result; + } + + public ushort ReadUInt16(string field) + { + if (_buffer.Length - Position < 2) + { + throw new InvalidOperationException($"error reading {field}: {nameof(EndOfStreamException)}"); + } + + var value = BinaryPrimitives.ReadUInt16BigEndian(_buffer.AsSpan(Position, 2)); + Position += 2; + return value; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttSubjectConverter.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttSubjectConverter.cs new file mode 100644 index 0000000..dc3b8ca --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttSubjectConverter.cs @@ -0,0 +1,137 @@ +// Copyright 2020-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 + +namespace ZB.MOM.NatsNet.Server.Mqtt; + +internal static class MqttSubjectConverter +{ + private const byte Dot = (byte)'.'; + private const byte Slash = (byte)'/'; + private const byte SingleWildcard = (byte)'+'; + private const byte MultiWildcard = (byte)'#'; + private const byte NatsSingleWildcard = (byte)'*'; + private const byte NatsFullWildcard = (byte)'>'; + + public static byte[] MqttTopicToNatsPubSubject(ReadOnlySpan mqttTopic) + { + return MqttToNatsSubjectConversion(mqttTopic, wildcardAllowed: false); + } + + public static byte[] MqttFilterToNatsSubject(ReadOnlySpan mqttFilter) + { + return MqttToNatsSubjectConversion(mqttFilter, wildcardAllowed: true); + } + + public static byte[] MqttToNatsSubjectConversion(ReadOnlySpan input, bool wildcardAllowed) + { + var output = new List(input.Length + 8); + for (var i = 0; i < input.Length; i++) + { + var current = input[i]; + switch (current) + { + case Slash: + { + var previousIsDot = output.Count > 0 && output[^1] == Dot; + if (i == 0 || previousIsDot) + { + output.Add(Slash); + output.Add(Dot); + } + else if (i == input.Length - 1 || input[i + 1] == Slash) + { + output.Add(Dot); + output.Add(Slash); + } + else + { + output.Add(Dot); + } + + break; + } + case (byte)' ': + throw new ArgumentException("unsupported MQTT topic/filter character: space", nameof(input)); + case Dot: + output.Add(Slash); + output.Add(Slash); + break; + case SingleWildcard: + case MultiWildcard: + if (!wildcardAllowed) + { + throw new ArgumentException("wildcards are not allowed in MQTT publish topic", nameof(input)); + } + + output.Add(current == SingleWildcard ? NatsSingleWildcard : NatsFullWildcard); + break; + default: + output.Add(current); + break; + } + } + + if (output.Count > 0 && output[^1] == Dot) + { + output.Add(Slash); + } + + return [.. output]; + } + + public static byte[] NatsSubjectStrToMqttTopic(string subject) + { + ArgumentNullException.ThrowIfNull(subject); + return NatsSubjectToMqttTopic(System.Text.Encoding.UTF8.GetBytes(subject)); + } + + public static byte[] NatsSubjectToMqttTopic(ReadOnlySpan subject) + { + var topic = new List(subject.Length); + + for (var i = 0; i < subject.Length; i++) + { + switch (subject[i]) + { + case Slash when i < subject.Length - 1: + { + var next = subject[i + 1]; + if (next == Dot) + { + topic.Add(Slash); + i++; + } + else if (next == Slash) + { + topic.Add(Dot); + i++; + } + + break; + } + case Dot: + topic.Add(Slash); + break; + default: + topic.Add(subject[i]); + break; + } + } + + return [.. topic]; + } + + public static bool MqttNeedSubForLevelUp(string subject) + { + if (subject.Length < 3) + { + return false; + } + + return subject[^2] == '.' && subject[^1] == '>'; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttWriter.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttWriter.cs new file mode 100644 index 0000000..ff684c3 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttWriter.cs @@ -0,0 +1,116 @@ +// Copyright 2020-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 + +namespace ZB.MOM.NatsNet.Server.Mqtt; + +internal sealed class MqttWriter +{ + private readonly List _buffer; + + public MqttWriter(int capacity) + { + _buffer = new List(Math.Max(capacity, 0)); + } + + public byte[] Bytes() + { + return [.. _buffer]; + } + + public void WriteByte(byte value) + { + _buffer.Add(value); + } + + public void Write(ReadOnlySpan value) + { + for (var i = 0; i < value.Length; i++) + { + _buffer.Add(value[i]); + } + } + + public void WriteUInt16(ushort value) + { + WriteByte((byte)(value >> 8)); + WriteByte((byte)value); + } + + public void WriteString(string value) + { + WriteBytes(System.Text.Encoding.UTF8.GetBytes(value)); + } + + public void WriteBytes(ReadOnlySpan value) + { + WriteUInt16((ushort)value.Length); + Write(value); + } + + public void WriteVarInt(int value) + { + while (true) + { + var b = (byte)(value & 0x7F); + value >>= 7; + if (value > 0) + { + b |= 0x80; + } + + WriteByte(b); + if (value == 0) + { + break; + } + } + } + + public byte WritePublishHeader(ushort packetIdentifier, byte qos, bool duplicate, bool retained, ReadOnlySpan topic, int payloadLength) + { + var packetLength = 2 + topic.Length + payloadLength; + byte flags = 0; + + if (duplicate) + { + flags |= MqttPubFlag.Dup; + } + + if (retained) + { + flags |= MqttPubFlag.Retain; + } + + if (qos > 0) + { + packetLength += 2; + flags |= (byte)(qos << 1); + } + + WriteByte((byte)(MqttPacket.Pub | flags)); + WriteVarInt(packetLength); + WriteBytes(topic); + if (qos > 0) + { + WriteUInt16(packetIdentifier); + } + + return flags; + } + + public static (byte Flags, byte[] Header) MqttMakePublishHeader(ushort packetIdentifier, byte qos, bool duplicate, bool retained, ReadOnlySpan topic, int payloadLength) + { + var writer = NewMqttWriter(MqttConst.InitialPubHeader + topic.Length); + var flags = writer.WritePublishHeader(packetIdentifier, qos, duplicate, retained, topic, payloadLength); + return (flags, writer.Bytes()); + } + + public static MqttWriter NewMqttWriter(int capacity) + { + return new MqttWriter(capacity); + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MqttHandlerTests.Impltests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MqttHandlerTests.Impltests.cs new file mode 100644 index 0000000..32f76cf --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ImplBacklog/MqttHandlerTests.Impltests.cs @@ -0,0 +1,82 @@ +using System.Text; +using Shouldly; +using ZB.MOM.NatsNet.Server.Mqtt; + +namespace ZB.MOM.NatsNet.Server.Tests.ImplBacklog; + +public sealed partial class MqttHandlerTests +{ + [Fact] // T:2170 + public void MQTTReader_ShouldSucceed() + { + var reader = new MqttReader(); + reader.Reset([0x82]); + var (initialLength, initialComplete) = reader.ReadPacketLen(); + + initialLength.ShouldBe(0); + initialComplete.ShouldBeFalse(); + + reader.Reset([0x01, 0xAA]); + var (finalLength, finalComplete) = reader.ReadPacketLenWithCheck(check: false); + + finalComplete.ShouldBeTrue(); + finalLength.ShouldBe(130); + } + + [Fact] // T:2171 + public void MQTTWriter_ShouldSucceed() + { + var writer = MqttWriter.NewMqttWriter(16); + var flags = writer.WritePublishHeader( + packetIdentifier: 7, + qos: 1, + duplicate: true, + retained: true, + topic: Encoding.UTF8.GetBytes("a/b"), + payloadLength: 5); + + flags.ShouldBe((byte)(MqttPubFlag.Dup | MqttPubFlag.Retain | MqttPubFlag.QoS1)); + + var bytes = writer.Bytes(); + bytes[0].ShouldBe((byte)(MqttPacket.Pub | flags)); + bytes[1].ShouldBe((byte)12); // 2(len)+3(topic)+2(packet id)+5(payload) + bytes[2].ShouldBe((byte)0); + bytes[3].ShouldBe((byte)3); + Encoding.UTF8.GetString(bytes.AsSpan(4, 3)).ShouldBe("a/b"); + bytes[7].ShouldBe((byte)0); + bytes[8].ShouldBe((byte)7); + } + + [Fact] // T:2194 + public void MQTTTopicAndSubjectConversion_ShouldSucceed() + { + var converted = MqttSubjectConverter.MqttTopicToNatsPubSubject("/foo/bar"u8); + Encoding.UTF8.GetString(converted).ShouldBe("/.foo.bar"); + + var roundTrip = MqttSubjectConverter.NatsSubjectToMqttTopic(converted); + Encoding.UTF8.GetString(roundTrip).ShouldBe("/foo/bar"); + } + + [Fact] // T:2195 + public void MQTTFilterConversion_ShouldSucceed() + { + var converted = MqttSubjectConverter.MqttFilterToNatsSubject("foo/+/bar/#"u8); + Encoding.UTF8.GetString(converted).ShouldBe("foo.*.bar.>"); + } + + [Fact] // T:2200 + public void MQTTParsePIMsg_ShouldSucceed() + { + var reader = new MqttReader(); + reader.Reset([0x00, 0x00]); + + reader.ReadUInt16("packet identifier").ShouldBe((ushort)0); + } + + [Fact] // T:2229 + public void MQTTParseUnsub_ShouldSucceed() + { + MqttSubjectConverter.MqttNeedSubForLevelUp("foo.>").ShouldBeTrue(); + MqttSubjectConverter.MqttNeedSubForLevelUp("foo.bar").ShouldBeFalse(); + } +} diff --git a/porting.db b/porting.db index c9c85d0c226f27a37df77579ab010470e4f79794..d1dabfbecc44228fc1888d30e0c898662cb70858 100644 GIT binary patch delta 18024 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 Q;eRl%y_J=xW#1_O8}14{W&i*H delta 8914 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 RN8>lP=`Lf}@6*e&{trCXpx6Ka