- Fix pull consumer fetch: send original stream subject in HMSG (not inbox) so NATS client distinguishes data messages from control messages - Fix MaxAge expiry: add background timer in StreamManager for periodic pruning - Fix JetStream wire format: Go-compatible anonymous objects with string enums, proper offset-based pagination for stream/consumer list APIs - Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream) - Add ~1000 parity tests across all subsystems (gaps closure) - Update gap inventory docs to reflect implementation status
332 lines
13 KiB
C#
332 lines
13 KiB
C#
// Binary MQTT packet body decoder.
|
||
// Go reference: golang/nats-server/server/mqtt.go
|
||
// CONNECT parsing — mqttParseSub / mqttParseConnect (lines ~700–850)
|
||
// PUBLISH parsing — mqttParsePublish (lines ~1200–1300)
|
||
// SUBSCRIBE parsing — mqttParseSub (lines ~1400–1500)
|
||
// Wildcard translation — mqttToNATSSubjectConversion (lines ~2200–2250)
|
||
|
||
namespace NATS.Server.Mqtt;
|
||
|
||
/// <summary>
|
||
/// Decoded fields from an MQTT CONNECT packet body.
|
||
/// Go reference: server/mqtt.go mqttParseConnect ~line 700.
|
||
/// </summary>
|
||
public readonly record struct MqttConnectInfo(
|
||
string ProtocolName,
|
||
byte ProtocolLevel,
|
||
bool CleanSession,
|
||
ushort KeepAlive,
|
||
string ClientId,
|
||
string? WillTopic,
|
||
byte[]? WillMessage,
|
||
byte WillQoS,
|
||
bool WillRetain,
|
||
string? Username,
|
||
string? Password);
|
||
|
||
/// <summary>
|
||
/// Decoded fields from an MQTT PUBLISH packet body.
|
||
/// Go reference: server/mqtt.go mqttParsePublish ~line 1200.
|
||
/// </summary>
|
||
public readonly record struct MqttPublishInfo(
|
||
string Topic,
|
||
ushort PacketId,
|
||
byte QoS,
|
||
bool Dup,
|
||
bool Retain,
|
||
ReadOnlyMemory<byte> Payload);
|
||
|
||
/// <summary>
|
||
/// Decoded fields from an MQTT SUBSCRIBE packet body.
|
||
/// Go reference: server/mqtt.go mqttParseSub ~line 1400.
|
||
/// </summary>
|
||
public readonly record struct MqttSubscribeInfo(
|
||
ushort PacketId,
|
||
IReadOnlyList<(string TopicFilter, byte QoS)> Filters);
|
||
|
||
/// <summary>
|
||
/// Decodes the variable-header and payload of CONNECT, PUBLISH, and SUBSCRIBE
|
||
/// MQTT 3.1.1 control packets, and translates MQTT wildcards to NATS subjects.
|
||
/// </summary>
|
||
public static class MqttBinaryDecoder
|
||
{
|
||
// -------------------------------------------------------------------------
|
||
// CONNECT parsing
|
||
// Go reference: server/mqtt.go mqttParseConnect ~line 700
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Parses the payload bytes of an MQTT CONNECT packet (everything after the
|
||
/// fixed header and remaining-length bytes, i.e. the value of
|
||
/// <see cref="MqttControlPacket.Payload"/>).
|
||
/// </summary>
|
||
/// <param name="payload">
|
||
/// The payload bytes as returned by <see cref="MqttPacketReader.Read"/>.
|
||
/// </param>
|
||
/// <returns>A populated <see cref="MqttConnectInfo"/>.</returns>
|
||
/// <exception cref="FormatException">
|
||
/// Thrown when the packet is malformed or the protocol name is not "MQTT".
|
||
/// </exception>
|
||
public static MqttConnectInfo ParseConnect(ReadOnlySpan<byte> payload)
|
||
{
|
||
// Variable header layout (MQTT 3.1.1 spec §3.1):
|
||
// 2-byte length prefix + protocol name bytes ("MQTT")
|
||
// 1 byte protocol level (4 = 3.1.1, 5 = 5.0)
|
||
// 1 byte connect flags
|
||
// 2 bytes keepalive (big-endian)
|
||
// Payload:
|
||
// 2+N client ID
|
||
// if will flag: 2+N will topic, 2+N will message
|
||
// if username: 2+N username
|
||
// if password: 2+N password
|
||
|
||
var pos = 0;
|
||
|
||
// Protocol name
|
||
var protocolName = ReadUtf8String(payload, ref pos);
|
||
if (protocolName != "MQTT" && protocolName != "MQIsdp")
|
||
throw new FormatException($"Unknown MQTT protocol name: '{protocolName}'");
|
||
|
||
if (pos + 4 > payload.Length)
|
||
throw new FormatException("MQTT CONNECT packet too short for variable header.");
|
||
|
||
var protocolLevel = payload[pos++];
|
||
|
||
// Connect flags byte
|
||
// Bit 1 = CleanSession, Bit 2 = WillFlag, Bits 3-4 = WillQoS, Bit 5 = WillRetain,
|
||
// Bit 6 = PasswordFlag, Bit 7 = UsernameFlag
|
||
var connectFlags = payload[pos++];
|
||
var cleanSession = (connectFlags & 0x02) != 0;
|
||
var willFlag = (connectFlags & 0x04) != 0;
|
||
var willQoS = (byte)((connectFlags >> 3) & 0x03);
|
||
var willRetain = (connectFlags & 0x20) != 0;
|
||
var passwordFlag = (connectFlags & 0x40) != 0;
|
||
var usernameFlag = (connectFlags & 0x80) != 0;
|
||
|
||
// Keep-alive (big-endian uint16)
|
||
var keepAlive = ReadUInt16BigEndian(payload, ref pos);
|
||
|
||
// Payload fields
|
||
var clientId = ReadUtf8String(payload, ref pos);
|
||
|
||
string? willTopic = null;
|
||
byte[]? willMessage = null;
|
||
if (willFlag)
|
||
{
|
||
willTopic = ReadUtf8String(payload, ref pos);
|
||
willMessage = ReadBinaryField(payload, ref pos);
|
||
}
|
||
|
||
string? username = null;
|
||
if (usernameFlag)
|
||
username = ReadUtf8String(payload, ref pos);
|
||
|
||
string? password = null;
|
||
if (passwordFlag)
|
||
password = ReadUtf8String(payload, ref pos);
|
||
|
||
return new MqttConnectInfo(
|
||
ProtocolName: protocolName,
|
||
ProtocolLevel: protocolLevel,
|
||
CleanSession: cleanSession,
|
||
KeepAlive: keepAlive,
|
||
ClientId: clientId,
|
||
WillTopic: willTopic,
|
||
WillMessage: willMessage,
|
||
WillQoS: willQoS,
|
||
WillRetain: willRetain,
|
||
Username: username,
|
||
Password: password);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// PUBLISH parsing
|
||
// Go reference: server/mqtt.go mqttParsePublish ~line 1200
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Parses the payload bytes of an MQTT PUBLISH packet.
|
||
/// The <paramref name="flags"/> nibble comes from
|
||
/// <see cref="MqttControlPacket.Flags"/> of the fixed header.
|
||
/// </summary>
|
||
/// <param name="payload">The payload bytes from <see cref="MqttControlPacket.Payload"/>.</param>
|
||
/// <param name="flags">The lower nibble of the fixed header byte (DUP/QoS/RETAIN flags).</param>
|
||
/// <returns>A populated <see cref="MqttPublishInfo"/>.</returns>
|
||
public static MqttPublishInfo ParsePublish(ReadOnlySpan<byte> payload, byte flags)
|
||
{
|
||
// Fixed-header flags nibble layout (MQTT 3.1.1 spec §3.3.1):
|
||
// Bit 3 = DUP
|
||
// Bits 2-1 = QoS (0, 1, or 2)
|
||
// Bit 0 = RETAIN
|
||
var dup = (flags & 0x08) != 0;
|
||
var qos = (byte)((flags >> 1) & 0x03);
|
||
var retain = (flags & 0x01) != 0;
|
||
|
||
var pos = 0;
|
||
|
||
// Variable header: topic name (2-byte length prefix + UTF-8)
|
||
var topic = ReadUtf8String(payload, ref pos);
|
||
|
||
// Packet identifier — only present for QoS > 0
|
||
ushort packetId = 0;
|
||
if (qos > 0)
|
||
packetId = ReadUInt16BigEndian(payload, ref pos);
|
||
|
||
// Remaining bytes are the application payload
|
||
var messagePayload = payload[pos..].ToArray();
|
||
|
||
return new MqttPublishInfo(
|
||
Topic: topic,
|
||
PacketId: packetId,
|
||
QoS: qos,
|
||
Dup: dup,
|
||
Retain: retain,
|
||
Payload: messagePayload);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// SUBSCRIBE parsing
|
||
// Go reference: server/mqtt.go mqttParseSub ~line 1400
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Parses the payload bytes of an MQTT SUBSCRIBE packet.
|
||
/// </summary>
|
||
/// <param name="payload">The payload bytes from <see cref="MqttControlPacket.Payload"/>.</param>
|
||
/// <param name="flags">
|
||
/// Optional fixed-header flags nibble. When provided, must match SUBSCRIBE flags (0x02).
|
||
/// </param>
|
||
/// <returns>A populated <see cref="MqttSubscribeInfo"/>.</returns>
|
||
public static MqttSubscribeInfo ParseSubscribe(ReadOnlySpan<byte> payload, byte? flags = null)
|
||
{
|
||
if (flags.HasValue && flags.Value != MqttProtocolConstants.SubscribeFlags)
|
||
throw new FormatException("MQTT SUBSCRIBE packet has invalid fixed-header flags.");
|
||
|
||
// Variable header: packet identifier (2 bytes, big-endian)
|
||
// Payload: one or more topic-filter entries, each:
|
||
// 2-byte length prefix + UTF-8 filter string + 1-byte requested QoS
|
||
|
||
var pos = 0;
|
||
var packetId = ReadUInt16BigEndian(payload, ref pos);
|
||
|
||
var filters = new List<(string, byte)>();
|
||
while (pos < payload.Length)
|
||
{
|
||
var topicFilter = ReadUtf8String(payload, ref pos);
|
||
if (pos >= payload.Length)
|
||
throw new FormatException("MQTT SUBSCRIBE packet missing QoS byte after topic filter.");
|
||
var filterQoS = payload[pos++];
|
||
filters.Add((topicFilter, filterQoS));
|
||
}
|
||
|
||
return new MqttSubscribeInfo(packetId, filters);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// MQTT wildcard → NATS subject translation
|
||
// Go reference: server/mqtt.go mqttToNATSSubjectConversion ~line 2200
|
||
//
|
||
// Simple translation (filter → NATS, wildcards permitted):
|
||
// '+' → '*' (single-level wildcard)
|
||
// '#' → '>' (multi-level wildcard)
|
||
// '/' → '.' (topic separator)
|
||
//
|
||
// NOTE: This method implements the simple/naïve translation that the task
|
||
// description specifies. The full Go implementation also handles dots,
|
||
// leading/trailing slashes, and empty levels differently (see
|
||
// MqttTopicMappingParityTests for the complete behavior). This method is
|
||
// intentionally limited to the four rules requested by the task spec.
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Translates an MQTT topic filter to a NATS subject using the simple rules:
|
||
/// <list type="bullet">
|
||
/// <item><c>+</c> → <c>*</c> (single-level wildcard)</item>
|
||
/// <item><c>#</c> → <c>></c> (multi-level wildcard)</item>
|
||
/// <item><c>/</c> → <c>.</c> (separator)</item>
|
||
/// </list>
|
||
/// Go reference: server/mqtt.go mqttToNATSSubjectConversion ~line 2200.
|
||
/// </summary>
|
||
/// <param name="mqttFilter">An MQTT topic filter string.</param>
|
||
/// <returns>The equivalent NATS subject string.</returns>
|
||
public static string TranslateFilterToNatsSubject(string mqttFilter)
|
||
{
|
||
if (mqttFilter.Length == 0)
|
||
return string.Empty;
|
||
|
||
var result = new char[mqttFilter.Length];
|
||
for (var i = 0; i < mqttFilter.Length; i++)
|
||
{
|
||
result[i] = mqttFilter[i] switch
|
||
{
|
||
'+' => '*',
|
||
'#' => '>',
|
||
'/' => '.',
|
||
var c => c,
|
||
};
|
||
}
|
||
|
||
return new string(result);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Internal helpers
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Reads a 2-byte big-endian length-prefixed UTF-8 string from
|
||
/// <paramref name="data"/> starting at <paramref name="pos"/>, advancing
|
||
/// <paramref name="pos"/> past the consumed bytes.
|
||
/// </summary>
|
||
private static string ReadUtf8String(ReadOnlySpan<byte> data, ref int pos)
|
||
{
|
||
if (pos + 2 > data.Length)
|
||
throw new FormatException("MQTT packet truncated reading string length prefix.");
|
||
|
||
var length = (data[pos] << 8) | data[pos + 1];
|
||
pos += 2;
|
||
|
||
if (pos + length > data.Length)
|
||
throw new FormatException("MQTT packet truncated reading string body.");
|
||
|
||
var value = System.Text.Encoding.UTF8.GetString(data.Slice(pos, length));
|
||
pos += length;
|
||
return value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads a 2-byte big-endian length-prefixed binary field (e.g. will
|
||
/// message, password) from <paramref name="data"/>, advancing
|
||
/// <paramref name="pos"/> past the consumed bytes.
|
||
/// </summary>
|
||
private static byte[] ReadBinaryField(ReadOnlySpan<byte> data, ref int pos)
|
||
{
|
||
if (pos + 2 > data.Length)
|
||
throw new FormatException("MQTT packet truncated reading binary field length prefix.");
|
||
|
||
var length = (data[pos] << 8) | data[pos + 1];
|
||
pos += 2;
|
||
|
||
if (pos + length > data.Length)
|
||
throw new FormatException("MQTT packet truncated reading binary field body.");
|
||
|
||
var value = data.Slice(pos, length).ToArray();
|
||
pos += length;
|
||
return value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads a big-endian uint16 from <paramref name="data"/> at
|
||
/// <paramref name="pos"/>, advancing <paramref name="pos"/> by 2.
|
||
/// </summary>
|
||
private static ushort ReadUInt16BigEndian(ReadOnlySpan<byte> data, ref int pos)
|
||
{
|
||
if (pos + 2 > data.Length)
|
||
throw new FormatException("MQTT packet truncated reading uint16.");
|
||
|
||
var value = (ushort)((data[pos] << 8) | data[pos + 1]);
|
||
pos += 2;
|
||
return value;
|
||
}
|
||
}
|