From e87d4c00d9e80162bd07dbc9dacc96c5e8f1d51c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 00:33:24 -0500 Subject: [PATCH] feat: add NatsHeaderParser for MIME header parsing --- src/NATS.Server/Protocol/NatsHeaderParser.cs | 101 ++++++++++++++++++ .../NatsHeaderParserTests.cs | 51 +++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/NATS.Server/Protocol/NatsHeaderParser.cs create mode 100644 tests/NATS.Server.Tests/NatsHeaderParserTests.cs diff --git a/src/NATS.Server/Protocol/NatsHeaderParser.cs b/src/NATS.Server/Protocol/NatsHeaderParser.cs new file mode 100644 index 0000000..669be0c --- /dev/null +++ b/src/NATS.Server/Protocol/NatsHeaderParser.cs @@ -0,0 +1,101 @@ +using System.Text; + +namespace NATS.Server.Protocol; + +public readonly struct NatsHeaders +{ + public int Status { get; init; } + public string Description { get; init; } + public Dictionary Headers { get; init; } + + public static readonly NatsHeaders Invalid = new() { Status = -1, Description = string.Empty, Headers = new() }; +} + +public static class NatsHeaderParser +{ + private static readonly byte[] CrLf = "\r\n"u8.ToArray(); + private static readonly byte[] Prefix = "NATS/1.0"u8.ToArray(); + + public static NatsHeaders Parse(ReadOnlySpan data) + { + if (data.Length < Prefix.Length) + return NatsHeaders.Invalid; + + if (!data[..Prefix.Length].SequenceEqual(Prefix)) + return NatsHeaders.Invalid; + + int pos = Prefix.Length; + int status = 0; + string description = string.Empty; + + // Parse status line: NATS/1.0[ status[ description]]\r\n + int lineEnd = data[pos..].IndexOf(CrLf); + if (lineEnd < 0) + return NatsHeaders.Invalid; + + var statusLine = data[pos..(pos + lineEnd)]; + pos += lineEnd + 2; // skip \r\n + + if (statusLine.Length > 0) + { + int si = 0; + while (si < statusLine.Length && statusLine[si] == (byte)' ') + si++; + + int numStart = si; + while (si < statusLine.Length && statusLine[si] >= (byte)'0' && statusLine[si] <= (byte)'9') + si++; + + if (si > numStart) + { + status = int.Parse(Encoding.ASCII.GetString(statusLine[numStart..si])); + + while (si < statusLine.Length && statusLine[si] == (byte)' ') + si++; + if (si < statusLine.Length) + description = Encoding.ASCII.GetString(statusLine[si..]); + } + } + + // Parse key-value headers until empty line + var headers = new Dictionary>(StringComparer.OrdinalIgnoreCase); + while (pos < data.Length) + { + var remaining = data[pos..]; + if (remaining.Length >= 2 && remaining[0] == (byte)'\r' && remaining[1] == (byte)'\n') + break; + + lineEnd = remaining.IndexOf(CrLf); + if (lineEnd < 0) + break; + + var headerLine = remaining[..lineEnd]; + pos += lineEnd + 2; + + int colon = headerLine.IndexOf((byte)':'); + if (colon < 0) + continue; + + var key = Encoding.ASCII.GetString(headerLine[..colon]).Trim(); + var value = Encoding.ASCII.GetString(headerLine[(colon + 1)..]).Trim(); + + if (!headers.TryGetValue(key, out var values)) + { + values = []; + headers[key] = values; + } + values.Add(value); + } + + var result = new Dictionary(headers.Count, StringComparer.OrdinalIgnoreCase); + foreach (var (k, v) in headers) + result[k] = v.ToArray(); + + return new NatsHeaders + { + Status = status, + Description = description, + Headers = result, + }; + } +} diff --git a/tests/NATS.Server.Tests/NatsHeaderParserTests.cs b/tests/NATS.Server.Tests/NatsHeaderParserTests.cs new file mode 100644 index 0000000..6e6cb1f --- /dev/null +++ b/tests/NATS.Server.Tests/NatsHeaderParserTests.cs @@ -0,0 +1,51 @@ +using NATS.Server.Protocol; + +namespace NATS.Server.Tests; + +public class NatsHeaderParserTests +{ + [Fact] + public void Parse_status_line_only() + { + var input = "NATS/1.0 503\r\n\r\n"u8; + var result = NatsHeaderParser.Parse(input); + result.Status.ShouldBe(503); + result.Description.ShouldBeEmpty(); + result.Headers.ShouldBeEmpty(); + } + + [Fact] + public void Parse_status_with_description() + { + var input = "NATS/1.0 503 No Responders\r\n\r\n"u8; + var result = NatsHeaderParser.Parse(input); + result.Status.ShouldBe(503); + result.Description.ShouldBe("No Responders"); + } + + [Fact] + public void Parse_headers_with_values() + { + var input = "NATS/1.0\r\nFoo: bar\r\nBaz: qux\r\n\r\n"u8; + var result = NatsHeaderParser.Parse(input); + result.Status.ShouldBe(0); + result.Headers["Foo"].ShouldBe(["bar"]); + result.Headers["Baz"].ShouldBe(["qux"]); + } + + [Fact] + public void Parse_multi_value_header() + { + var input = "NATS/1.0\r\nX-Tag: a\r\nX-Tag: b\r\n\r\n"u8; + var result = NatsHeaderParser.Parse(input); + result.Headers["X-Tag"].ShouldBe(["a", "b"]); + } + + [Fact] + public void Parse_invalid_returns_defaults() + { + var input = "GARBAGE\r\n\r\n"u8; + var result = NatsHeaderParser.Parse(input); + result.Status.ShouldBe(-1); + } +}