Files
natsdotnet/src/NATS.Server/Mqtt/MqttBinaryDecoder.cs
Joseph Doherty c30e67a69d Fix E2E test gaps and add comprehensive E2E + parity test suites
- 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
2026-03-12 14:09:23 -04:00

332 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Binary MQTT packet body decoder.
// Go reference: golang/nats-server/server/mqtt.go
// CONNECT parsing — mqttParseSub / mqttParseConnect (lines ~700850)
// PUBLISH parsing — mqttParsePublish (lines ~12001300)
// SUBSCRIBE parsing — mqttParseSub (lines ~14001500)
// Wildcard translation — mqttToNATSSubjectConversion (lines ~22002250)
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>&gt;</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;
}
}