From 9fff5709c4badda7f7aad200337c79abe0067f24 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 04:20:56 -0500 Subject: [PATCH 1/9] feat: add NATS config file lexer (port of Go conf/lex.go) Port the NATS configuration file lexer from Go's conf/lex.go to C#. The lexer is a state-machine tokenizer that supports the NATS config format: key-value pairs with =, :, or whitespace separators; nested maps {}; arrays []; single and double quoted strings with escape sequences; block strings (); variables $VAR; include directives; comments (# and //); booleans; integers with size suffixes (kb, mb, gb); floats; ISO8601 datetimes; and IP addresses. --- .../Configuration/NatsConfLexer.cs | 1492 +++++++++++++++++ .../Configuration/NatsConfToken.cs | 24 + tests/NATS.Server.Tests/NatsConfLexerTests.cs | 221 +++ 3 files changed, 1737 insertions(+) create mode 100644 src/NATS.Server/Configuration/NatsConfLexer.cs create mode 100644 src/NATS.Server/Configuration/NatsConfToken.cs create mode 100644 tests/NATS.Server.Tests/NatsConfLexerTests.cs diff --git a/src/NATS.Server/Configuration/NatsConfLexer.cs b/src/NATS.Server/Configuration/NatsConfLexer.cs new file mode 100644 index 0000000..8f878fb --- /dev/null +++ b/src/NATS.Server/Configuration/NatsConfLexer.cs @@ -0,0 +1,1492 @@ +// Port of Go conf/lex.go — state-machine tokenizer for NATS config files. +// Reference: golang/nats-server/conf/lex.go + +namespace NATS.Server.Configuration; + +public sealed class NatsConfLexer +{ + private const char Eof = '\0'; + private const char MapStartChar = '{'; + private const char MapEndChar = '}'; + private const char KeySepEqual = '='; + private const char KeySepColon = ':'; + private const char ArrayStartChar = '['; + private const char ArrayEndChar = ']'; + private const char ArrayValTerm = ','; + private const char MapValTerm = ','; + private const char CommentHashStart = '#'; + private const char CommentSlashStart = '/'; + private const char DqStringStart = '"'; + private const char DqStringEnd = '"'; + private const char SqStringStart = '\''; + private const char SqStringEnd = '\''; + private const char OptValTerm = ';'; + private const char TopOptStart = '{'; + private const char TopOptTerm = '}'; + private const char TopOptValTerm = ','; + private const char BlockStartChar = '('; + private const char BlockEndChar = ')'; + + private delegate LexState? LexState(NatsConfLexer lx); + + private readonly string _input; + private int _start; + private int _pos; + private int _width; + private int _line; + private readonly List _items; + private readonly Stack _stack; + private readonly List _stringParts; + private LexState? _stringStateFn; + + // Start position of the current line (after newline character). + private int _lstart; + + // Start position of the line from the current item. + private int _ilstart; + + private NatsConfLexer(string input) + { + _input = input; + _start = 0; + _pos = 0; + _width = 0; + _line = 1; + _items = []; + _stack = new Stack(); + _stringParts = []; + _lstart = 0; + _ilstart = 0; + } + + public static IReadOnlyList Tokenize(string input) + { + var lx = new NatsConfLexer(input); + LexState? state = LexTop; + while (state is not null) + { + state = state(lx); + } + + return lx._items; + } + + private void Push(LexState state) => _stack.Push(state); + + private LexState? Pop() + { + if (_stack.Count == 0) + { + return Errorf("BUG in lexer: no states to pop."); + } + + return _stack.Pop(); + } + + private void Emit(TokenType type) + { + var val = string.Concat(_stringParts) + _input[_start.._pos]; + var pos = _pos - _ilstart - val.Length; + _items.Add(new Token(type, val, _line, pos)); + _start = _pos; + _ilstart = _lstart; + } + + private void EmitString() + { + string finalString; + if (_stringParts.Count > 0) + { + finalString = string.Concat(_stringParts) + _input[_start.._pos]; + _stringParts.Clear(); + } + else + { + finalString = _input[_start.._pos]; + } + + var pos = _pos - _ilstart - finalString.Length; + _items.Add(new Token(TokenType.String, finalString, _line, pos)); + _start = _pos; + _ilstart = _lstart; + } + + private void AddCurrentStringPart(int offset) + { + _stringParts.Add(_input[_start..(_pos - offset)]); + _start = _pos; + } + + private LexState? AddStringPart(string s) + { + _stringParts.Add(s); + _start = _pos; + return _stringStateFn; + } + + private bool HasEscapedParts() => _stringParts.Count > 0; + + private char Next() + { + if (_pos >= _input.Length) + { + _width = 0; + return Eof; + } + + if (_input[_pos] == '\n') + { + _line++; + _lstart = _pos; + } + + var c = _input[_pos]; + _width = 1; + _pos += _width; + return c; + } + + private void Ignore() + { + _start = _pos; + _ilstart = _lstart; + } + + private void Backup() + { + _pos -= _width; + if (_pos < _input.Length && _input[_pos] == '\n') + { + _line--; + } + } + + private char Peek() + { + var r = Next(); + Backup(); + return r; + } + + private LexState? Errorf(string message) + { + var pos = _pos - _lstart; + _items.Add(new Token(TokenType.Error, message, _line, pos)); + return null; + } + + // --- Helper methods --- + + private static bool IsWhitespace(char c) => c is '\t' or ' '; + + private static bool IsNL(char c) => c is '\n' or '\r'; + + private static bool IsKeySeparator(char c) => c is KeySepEqual or KeySepColon; + + private static bool IsNumberSuffix(char c) => + c is 'k' or 'K' or 'm' or 'M' or 'g' or 'G' or 't' or 'T' or 'p' or 'P' or 'e' or 'E'; + + // --- State functions --- + + private static LexState? LexTop(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsWhiteSpace(r)) + { + return LexSkip(lx, LexTop); + } + + switch (r) + { + case TopOptStart: + lx.Push(LexTop); + return LexSkip(lx, LexBlockStart); + case CommentHashStart: + lx.Push(LexTop); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexTop); + return LexCommentStart; + } + + lx.Backup(); + goto case Eof; + } + + case Eof: + if (lx._pos > lx._start) + { + return lx.Errorf("Unexpected EOF."); + } + + lx.Emit(TokenType.Eof); + return null; + } + + // Back up and let the key lexer handle it. + lx.Backup(); + lx.Push(LexTopValueEnd); + return LexKeyStart; + } + + private static LexState? LexTopValueEnd(NatsConfLexer lx) + { + var r = lx.Next(); + switch (r) + { + case CommentHashStart: + lx.Push(LexTop); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexTop); + return LexCommentStart; + } + + lx.Backup(); + if (IsWhitespace(r)) + { + return LexTopValueEnd; + } + + break; + } + + default: + if (IsWhitespace(r)) + { + return LexTopValueEnd; + } + + break; + } + + if (IsNL(r) || r == Eof || r == OptValTerm || r == TopOptValTerm || r == TopOptTerm) + { + lx.Ignore(); + return LexTop; + } + + return lx.Errorf($"Expected a top-level value to end with a new line, comment or EOF, but got '{EscapeSpecial(r)}' instead."); + } + + private static LexState? LexBlockStart(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsWhiteSpace(r)) + { + return LexSkip(lx, LexBlockStart); + } + + switch (r) + { + case TopOptStart: + lx.Push(LexBlockEnd); + return LexSkip(lx, LexBlockStart); + case TopOptTerm: + lx.Ignore(); + return lx.Pop(); + case CommentHashStart: + lx.Push(LexBlockStart); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexBlockStart); + return LexCommentStart; + } + + lx.Backup(); + goto case Eof; + } + + case Eof: + if (lx._pos > lx._start) + { + return lx.Errorf("Unexpected EOF."); + } + + lx.Emit(TokenType.Eof); + return null; + } + + lx.Backup(); + lx.Push(LexBlockValueEnd); + return LexKeyStart; + } + + private static LexState? LexBlockValueEnd(NatsConfLexer lx) + { + var r = lx.Next(); + switch (r) + { + case CommentHashStart: + lx.Push(LexBlockValueEnd); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexBlockValueEnd); + return LexCommentStart; + } + + lx.Backup(); + if (IsWhitespace(r)) + { + return LexBlockValueEnd; + } + + break; + } + + default: + if (IsWhitespace(r)) + { + return LexBlockValueEnd; + } + + break; + } + + if (IsNL(r) || r == OptValTerm || r == TopOptValTerm) + { + lx.Ignore(); + return LexBlockStart; + } + + if (r == TopOptTerm) + { + lx.Backup(); + return LexBlockEnd; + } + + return lx.Errorf($"Expected a block-level value to end with a new line, comment or EOF, but got '{EscapeSpecial(r)}' instead."); + } + + private static LexState? LexBlockEnd(NatsConfLexer lx) + { + var r = lx.Next(); + switch (r) + { + case CommentHashStart: + lx.Push(LexBlockStart); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexBlockStart); + return LexCommentStart; + } + + lx.Backup(); + if (IsNL(r) || IsWhitespace(r)) + { + return LexBlockEnd; + } + + break; + } + + default: + if (IsNL(r) || IsWhitespace(r)) + { + return LexBlockEnd; + } + + break; + } + + if (r == OptValTerm || r == TopOptValTerm) + { + lx.Ignore(); + return LexBlockStart; + } + + if (r == TopOptTerm) + { + lx.Ignore(); + return lx.Pop(); + } + + return lx.Errorf($"Expected a block-level to end with a '}}', but got '{EscapeSpecial(r)}' instead."); + } + + private static LexState? LexKeyStart(NatsConfLexer lx) + { + var r = lx.Peek(); + if (IsKeySeparator(r)) + { + return lx.Errorf($"Unexpected key separator '{r}'"); + } + + if (char.IsWhiteSpace(r)) + { + lx.Next(); + return LexSkip(lx, LexKeyStart); + } + + if (r == DqStringStart) + { + lx.Next(); + return LexSkip(lx, LexDubQuotedKey); + } + + if (r == SqStringStart) + { + lx.Next(); + return LexSkip(lx, LexQuotedKey); + } + + lx.Ignore(); + lx.Next(); + return LexKey; + } + + private static LexState? LexDubQuotedKey(NatsConfLexer lx) + { + var r = lx.Peek(); + if (r == DqStringEnd) + { + lx.Emit(TokenType.Key); + lx.Next(); + return LexSkip(lx, LexKeyEnd); + } + + if (r == Eof) + { + if (lx._pos > lx._start) + { + return lx.Errorf("Unexpected EOF."); + } + + lx.Emit(TokenType.Eof); + return null; + } + + lx.Next(); + return LexDubQuotedKey; + } + + private static LexState? LexQuotedKey(NatsConfLexer lx) + { + var r = lx.Peek(); + if (r == SqStringEnd) + { + lx.Emit(TokenType.Key); + lx.Next(); + return LexSkip(lx, LexKeyEnd); + } + + if (r == Eof) + { + if (lx._pos > lx._start) + { + return lx.Errorf("Unexpected EOF."); + } + + lx.Emit(TokenType.Eof); + return null; + } + + lx.Next(); + return LexQuotedKey; + } + + private LexState? KeyCheckKeyword(LexState fallThrough, LexState? push) + { + var key = _input[_start.._pos].ToLowerInvariant(); + if (key == "include") + { + Ignore(); + if (push is not null) + { + Push(push); + } + + return LexIncludeStart; + } + + Emit(TokenType.Key); + return fallThrough; + } + + private static LexState? LexIncludeStart(NatsConfLexer lx) + { + var r = lx.Next(); + if (IsWhitespace(r)) + { + return LexSkip(lx, LexIncludeStart); + } + + lx.Backup(); + return LexInclude; + } + + private static LexState? LexIncludeQuotedString(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == SqStringEnd) + { + lx.Backup(); + lx.Emit(TokenType.Include); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + if (r == Eof) + { + return lx.Errorf("Unexpected EOF in quoted include"); + } + + return LexIncludeQuotedString; + } + + private static LexState? LexIncludeDubQuotedString(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == DqStringEnd) + { + lx.Backup(); + lx.Emit(TokenType.Include); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + if (r == Eof) + { + return lx.Errorf("Unexpected EOF in double quoted include"); + } + + return LexIncludeDubQuotedString; + } + + private static LexState? LexIncludeString(NatsConfLexer lx) + { + var r = lx.Next(); + if (IsNL(r) || r == Eof || r == OptValTerm || r == MapEndChar || IsWhitespace(r)) + { + lx.Backup(); + lx.Emit(TokenType.Include); + return lx.Pop(); + } + + if (r == SqStringEnd) + { + lx.Backup(); + lx.Emit(TokenType.Include); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + return LexIncludeString; + } + + private static LexState? LexInclude(NatsConfLexer lx) + { + var r = lx.Next(); + switch (r) + { + case SqStringStart: + lx.Ignore(); + return LexIncludeQuotedString; + case DqStringStart: + lx.Ignore(); + return LexIncludeDubQuotedString; + case ArrayStartChar: + return lx.Errorf("Expected include value but found start of an array"); + case MapStartChar: + return lx.Errorf("Expected include value but found start of a map"); + case BlockStartChar: + return lx.Errorf("Expected include value but found start of a block"); + case '\\': + return lx.Errorf("Expected include value but found escape sequence"); + } + + if (char.IsDigit(r) || r == '-') + { + return lx.Errorf("Expected include value but found start of a number"); + } + + if (IsNL(r)) + { + return lx.Errorf("Expected include value but found new line"); + } + + lx.Backup(); + return LexIncludeString; + } + + private static LexState? LexKey(NatsConfLexer lx) + { + var r = lx.Peek(); + if (char.IsWhiteSpace(r) && !IsNL(r)) + { + // Spaces signal we could be looking at a keyword, e.g. include. + return lx.KeyCheckKeyword(LexKeyEnd, null); + } + + if (IsKeySeparator(r) || r == Eof) + { + lx.Emit(TokenType.Key); + return LexKeyEnd; + } + + if (IsNL(r)) + { + // Newline after key with no separator — check for keyword. + return lx.KeyCheckKeyword(LexKeyEnd, null); + } + + lx.Next(); + return LexKey; + } + + private static LexState? LexKeyEnd(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsWhiteSpace(r) && !IsNL(r)) + { + return LexSkip(lx, LexKeyEnd); + } + + if (IsKeySeparator(r)) + { + return LexSkip(lx, LexValue); + } + + if (r == Eof) + { + lx.Emit(TokenType.Eof); + return null; + } + + // We start the value here. + lx.Backup(); + return LexValue; + } + + private static LexState? LexValue(NatsConfLexer lx) + { + var r = lx.Next(); + if (IsWhitespace(r)) + { + return LexSkip(lx, LexValue); + } + + switch (r) + { + case ArrayStartChar: + lx.Ignore(); + lx.Emit(TokenType.ArrayStart); + return LexArrayValue; + case MapStartChar: + lx.Ignore(); + lx.Emit(TokenType.MapStart); + return LexMapKeyStart; + case SqStringStart: + lx.Ignore(); + return LexQuotedString; + case DqStringStart: + lx.Ignore(); + lx._stringStateFn = LexDubQuotedString; + return LexDubQuotedString; + case '-': + return LexNegNumberStart; + case BlockStartChar: + lx.Ignore(); + return LexBlock; + case '.': + return lx.Errorf("Floats must start with a digit"); + } + + if (char.IsDigit(r)) + { + lx.Backup(); + return LexNumberOrDateOrStringOrIPStart; + } + + if (IsNL(r)) + { + return lx.Errorf("Expected value but found new line"); + } + + lx.Backup(); + lx._stringStateFn = LexString; + return LexString; + } + + private static LexState? LexArrayValue(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsWhiteSpace(r)) + { + return LexSkip(lx, LexArrayValue); + } + + switch (r) + { + case CommentHashStart: + lx.Push(LexArrayValue); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexArrayValue); + return LexCommentStart; + } + + lx.Backup(); + // fallthrough to ArrayValTerm check + if (r == ArrayValTerm) + { + return lx.Errorf($"Unexpected array value terminator '{ArrayValTerm}'."); + } + + break; + } + + case ArrayValTerm: + return lx.Errorf($"Unexpected array value terminator '{ArrayValTerm}'."); + case ArrayEndChar: + return LexArrayEnd; + } + + lx.Backup(); + lx.Push(LexArrayValueEnd); + return LexValue; + } + + private static LexState? LexArrayValueEnd(NatsConfLexer lx) + { + var r = lx.Next(); + if (IsWhitespace(r)) + { + return LexSkip(lx, LexArrayValueEnd); + } + + switch (r) + { + case CommentHashStart: + lx.Push(LexArrayValueEnd); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexArrayValueEnd); + return LexCommentStart; + } + + lx.Backup(); + // fallthrough + if (r == ArrayValTerm || IsNL(r)) + { + return LexSkip(lx, LexArrayValue); + } + + break; + } + + case ArrayEndChar: + return LexArrayEnd; + } + + if (r == ArrayValTerm || IsNL(r)) + { + return LexSkip(lx, LexArrayValue); + } + + return lx.Errorf($"Expected an array value terminator ',' or an array terminator ']', but got '{EscapeSpecial(r)}' instead."); + } + + private static LexState? LexArrayEnd(NatsConfLexer lx) + { + lx.Ignore(); + lx.Emit(TokenType.ArrayEnd); + return lx.Pop(); + } + + private static LexState? LexMapKeyStart(NatsConfLexer lx) + { + var r = lx.Peek(); + if (IsKeySeparator(r)) + { + return lx.Errorf($"Unexpected key separator '{r}'."); + } + + if (r == ArrayEndChar) + { + return lx.Errorf($"Unexpected array end '{r}' processing map."); + } + + if (char.IsWhiteSpace(r)) + { + lx.Next(); + return LexSkip(lx, LexMapKeyStart); + } + + if (r == MapEndChar) + { + lx.Next(); + return LexSkip(lx, LexMapEnd); + } + + if (r == CommentHashStart) + { + lx.Next(); + lx.Push(LexMapKeyStart); + return LexCommentStart; + } + + if (r == CommentSlashStart) + { + lx.Next(); + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexMapKeyStart); + return LexCommentStart; + } + + lx.Backup(); + } + + if (r == SqStringStart) + { + lx.Next(); + return LexSkip(lx, LexMapQuotedKey); + } + + if (r == DqStringStart) + { + lx.Next(); + return LexSkip(lx, LexMapDubQuotedKey); + } + + if (r == Eof) + { + return lx.Errorf("Unexpected EOF processing map."); + } + + lx.Ignore(); + lx.Next(); + return LexMapKey; + } + + private static LexState? LexMapQuotedKey(NatsConfLexer lx) + { + var r = lx.Peek(); + if (r == Eof) + { + return lx.Errorf("Unexpected EOF processing quoted map key."); + } + + if (r == SqStringEnd) + { + lx.Emit(TokenType.Key); + lx.Next(); + return LexSkip(lx, LexMapKeyEnd); + } + + lx.Next(); + return LexMapQuotedKey; + } + + private static LexState? LexMapDubQuotedKey(NatsConfLexer lx) + { + var r = lx.Peek(); + if (r == Eof) + { + return lx.Errorf("Unexpected EOF processing double quoted map key."); + } + + if (r == DqStringEnd) + { + lx.Emit(TokenType.Key); + lx.Next(); + return LexSkip(lx, LexMapKeyEnd); + } + + lx.Next(); + return LexMapDubQuotedKey; + } + + private static LexState? LexMapKey(NatsConfLexer lx) + { + var r = lx.Peek(); + if (r == Eof) + { + return lx.Errorf("Unexpected EOF processing map key."); + } + + if (char.IsWhiteSpace(r) && !IsNL(r)) + { + return lx.KeyCheckKeyword(LexMapKeyEnd, LexMapValueEnd); + } + + if (IsNL(r)) + { + return lx.KeyCheckKeyword(LexMapKeyEnd, LexMapValueEnd); + } + + if (IsKeySeparator(r)) + { + lx.Emit(TokenType.Key); + return LexMapKeyEnd; + } + + lx.Next(); + return LexMapKey; + } + + private static LexState? LexMapKeyEnd(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsWhiteSpace(r) && !IsNL(r)) + { + return LexSkip(lx, LexMapKeyEnd); + } + + if (IsKeySeparator(r)) + { + return LexSkip(lx, LexMapValue); + } + + // We start the value here. + lx.Backup(); + return LexMapValue; + } + + private static LexState? LexMapValue(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsWhiteSpace(r)) + { + return LexSkip(lx, LexMapValue); + } + + if (r == MapValTerm) + { + return lx.Errorf($"Unexpected map value terminator '{MapValTerm}'."); + } + + if (r == MapEndChar) + { + return LexSkip(lx, LexMapEnd); + } + + lx.Backup(); + lx.Push(LexMapValueEnd); + return LexValue; + } + + private static LexState? LexMapValueEnd(NatsConfLexer lx) + { + var r = lx.Next(); + if (IsWhitespace(r)) + { + return LexSkip(lx, LexMapValueEnd); + } + + switch (r) + { + case CommentHashStart: + lx.Push(LexMapValueEnd); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexMapValueEnd); + return LexCommentStart; + } + + lx.Backup(); + // fallthrough + if (r == OptValTerm || r == MapValTerm || IsNL(r)) + { + return LexSkip(lx, LexMapKeyStart); + } + + break; + } + } + + if (r == OptValTerm || r == MapValTerm || IsNL(r)) + { + return LexSkip(lx, LexMapKeyStart); + } + + if (r == MapEndChar) + { + return LexSkip(lx, LexMapEnd); + } + + return lx.Errorf($"Expected a map value terminator ',' or a map terminator '}}', but got '{EscapeSpecial(r)}' instead."); + } + + private static LexState? LexMapEnd(NatsConfLexer lx) + { + lx.Ignore(); + lx.Emit(TokenType.MapEnd); + return lx.Pop(); + } + + private bool IsBool() + { + var str = _input[_start.._pos].ToLowerInvariant(); + return str is "true" or "false" or "on" or "off" or "yes" or "no"; + } + + private bool IsVariable() + { + if (_start >= _input.Length) + { + return false; + } + + if (_input[_start] == '$') + { + _start += 1; + return true; + } + + return false; + } + + private static LexState? LexQuotedString(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == SqStringEnd) + { + lx.Backup(); + lx.Emit(TokenType.String); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + if (r == Eof) + { + if (lx._pos > lx._start) + { + return lx.Errorf("Unexpected EOF."); + } + + lx.Emit(TokenType.Eof); + return null; + } + + return LexQuotedString; + } + + private static LexState? LexDubQuotedString(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == '\\') + { + lx.AddCurrentStringPart(1); + return LexStringEscape; + } + + if (r == DqStringEnd) + { + lx.Backup(); + lx.EmitString(); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + if (r == Eof) + { + if (lx._pos > lx._start) + { + return lx.Errorf("Unexpected EOF."); + } + + lx.Emit(TokenType.Eof); + return null; + } + + return LexDubQuotedString; + } + + private static LexState? LexString(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == '\\') + { + lx.AddCurrentStringPart(1); + return LexStringEscape; + } + + // Termination of non-quoted strings. + if (IsNL(r) || r == Eof || r == OptValTerm || + r == ArrayValTerm || r == ArrayEndChar || r == MapEndChar || + IsWhitespace(r)) + { + lx.Backup(); + if (lx.HasEscapedParts()) + { + lx.EmitString(); + } + else if (lx.IsBool()) + { + lx.Emit(TokenType.Bool); + } + else if (lx.IsVariable()) + { + lx.Emit(TokenType.Variable); + } + else + { + lx.EmitString(); + } + + return lx.Pop(); + } + + if (r == SqStringEnd) + { + lx.Backup(); + lx.EmitString(); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + return LexString; + } + + private static LexState? LexBlock(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == BlockEndChar) + { + lx.Backup(); + lx.Backup(); + + // Looking for a ')' character on a line by itself. + // If the previous character isn't a newline, keep processing. + if (lx.Next() != '\n') + { + lx.Next(); + return LexBlock; + } + + lx.Next(); + + // Make sure the next character is a newline or EOF. + var next = lx.Next(); + if (next is '\n' or Eof) + { + lx.Backup(); + lx.Backup(); + lx.Emit(TokenType.String); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + lx.Backup(); + return LexBlock; + } + + if (r == Eof) + { + return lx.Errorf("Unexpected EOF processing block."); + } + + return LexBlock; + } + + private static LexState? LexStringEscape(NatsConfLexer lx) + { + var r = lx.Next(); + return r switch + { + 'x' => LexStringBinary(lx), + 't' => lx.AddStringPart("\t"), + 'n' => lx.AddStringPart("\n"), + 'r' => lx.AddStringPart("\r"), + '"' => lx.AddStringPart("\""), + '\\' => lx.AddStringPart("\\"), + _ => lx.Errorf($"Invalid escape character '{EscapeSpecial(r)}'. Only the following escape characters are allowed: \\xXX, \\t, \\n, \\r, \\\", \\\\."), + }; + } + + private static LexState? LexStringBinary(NatsConfLexer lx) + { + var r1 = lx.Next(); + if (IsNL(r1)) + { + return lx.Errorf("Expected two hexadecimal digits after '\\x', but hit end of line"); + } + + var r2 = lx.Next(); + if (IsNL(r2)) + { + return lx.Errorf("Expected two hexadecimal digits after '\\x', but hit end of line"); + } + + var hexStr = lx._input[(lx._pos - 2)..lx._pos]; + try + { + var bytes = Convert.FromHexString(hexStr); + return lx.AddStringPart(System.Text.Encoding.Latin1.GetString(bytes)); + } + catch (FormatException) + { + return lx.Errorf($"Expected two hexadecimal digits after '\\x', but got '{hexStr}'"); + } + } + + private static LexState? LexNumberOrDateOrStringOrIPStart(NatsConfLexer lx) + { + var r = lx.Next(); + if (!char.IsDigit(r)) + { + if (r == '.') + { + return lx.Errorf("Floats must start with a digit, not '.'."); + } + + return lx.Errorf($"Expected a digit but got '{EscapeSpecial(r)}'."); + } + + return LexNumberOrDateOrStringOrIP; + } + + private static LexState? LexNumberOrDateOrStringOrIP(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == '-') + { + if (lx._pos - lx._start != 5) + { + return lx.Errorf("All ISO8601 dates must be in full Zulu form."); + } + + return LexDateAfterYear; + } + + if (char.IsDigit(r)) + { + return LexNumberOrDateOrStringOrIP; + } + + if (r == '.') + { + return LexFloatStart; + } + + if (IsNumberSuffix(r)) + { + return LexConvenientNumber; + } + + // Check if this is a terminator or a string character. + if (!(IsNL(r) || r == Eof || r == MapEndChar || r == OptValTerm || r == MapValTerm || IsWhitespace(r) || r == ArrayValTerm || r == ArrayEndChar)) + { + // Treat it as a string value. + lx._stringStateFn = LexString; + return LexString; + } + + lx.Backup(); + lx.Emit(TokenType.Integer); + return lx.Pop(); + } + + private static LexState? LexConvenientNumber(NatsConfLexer lx) + { + var r = lx.Next(); + if (r is 'b' or 'B' or 'i' or 'I') + { + return LexConvenientNumber; + } + + lx.Backup(); + if (IsNL(r) || r == Eof || r == MapEndChar || r == OptValTerm || r == MapValTerm || + IsWhitespace(r) || char.IsDigit(r) || r == ArrayValTerm || r == ArrayEndChar) + { + lx.Emit(TokenType.Integer); + return lx.Pop(); + } + + // This is not a number, treat as string. + lx._stringStateFn = LexString; + return LexString; + } + + private static LexState? LexDateAfterYear(NatsConfLexer lx) + { + // Expected: MM-DDTHH:MM:SSZ + char[] formats = + [ + '0', '0', '-', '0', '0', + 'T', + '0', '0', ':', '0', '0', ':', '0', '0', + 'Z', + ]; + + foreach (var f in formats) + { + var r = lx.Next(); + if (f == '0') + { + if (!char.IsDigit(r)) + { + return lx.Errorf($"Expected digit in ISO8601 datetime, but found '{EscapeSpecial(r)}' instead."); + } + } + else if (f != r) + { + return lx.Errorf($"Expected '{f}' in ISO8601 datetime, but found '{EscapeSpecial(r)}' instead."); + } + } + + lx.Emit(TokenType.DateTime); + return lx.Pop(); + } + + private static LexState? LexNegNumberStart(NatsConfLexer lx) + { + var r = lx.Next(); + if (!char.IsDigit(r)) + { + if (r == '.') + { + return lx.Errorf("Floats must start with a digit, not '.'."); + } + + return lx.Errorf($"Expected a digit but got '{EscapeSpecial(r)}'."); + } + + return LexNegNumber; + } + + private static LexState? LexNegNumber(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsDigit(r)) + { + return LexNegNumber; + } + + if (r == '.') + { + return LexFloatStart; + } + + if (IsNumberSuffix(r)) + { + return LexConvenientNumber; + } + + lx.Backup(); + lx.Emit(TokenType.Integer); + return lx.Pop(); + } + + private static LexState? LexFloatStart(NatsConfLexer lx) + { + var r = lx.Next(); + if (!char.IsDigit(r)) + { + return lx.Errorf($"Floats must have a digit after the '.', but got '{EscapeSpecial(r)}' instead."); + } + + return LexFloat; + } + + private static LexState? LexFloat(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsDigit(r)) + { + return LexFloat; + } + + // Not a digit; if it's another '.', this might be an IP address. + if (r == '.') + { + return LexIPAddr; + } + + lx.Backup(); + lx.Emit(TokenType.Float); + return lx.Pop(); + } + + private static LexState? LexIPAddr(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsDigit(r) || r is '.' or ':' or '-') + { + return LexIPAddr; + } + + lx.Backup(); + lx.Emit(TokenType.String); + return lx.Pop(); + } + + private static LexState? LexCommentStart(NatsConfLexer lx) + { + lx.Ignore(); + lx.Emit(TokenType.Comment); + return LexComment; + } + + private static LexState? LexComment(NatsConfLexer lx) + { + var r = lx.Peek(); + if (IsNL(r) || r == Eof) + { + // Consume the comment text but don't emit it as a user-visible token. + // Just ignore it and pop back. + lx.Ignore(); + return lx.Pop(); + } + + lx.Next(); + return LexComment; + } + + private static LexState LexSkip(NatsConfLexer lx, LexState nextState) + { + lx.Ignore(); + return nextState; + } + + private static string EscapeSpecial(char c) => c switch + { + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + Eof => "EOF", + _ => c.ToString(), + }; +} diff --git a/src/NATS.Server/Configuration/NatsConfToken.cs b/src/NATS.Server/Configuration/NatsConfToken.cs new file mode 100644 index 0000000..8054239 --- /dev/null +++ b/src/NATS.Server/Configuration/NatsConfToken.cs @@ -0,0 +1,24 @@ +// Port of Go conf/lex.go token types. + +namespace NATS.Server.Configuration; + +public enum TokenType +{ + Error, + Eof, + Key, + String, + Bool, + Integer, + Float, + DateTime, + ArrayStart, + ArrayEnd, + MapStart, + MapEnd, + Variable, + Include, + Comment, +} + +public readonly record struct Token(TokenType Type, string Value, int Line, int Position); diff --git a/tests/NATS.Server.Tests/NatsConfLexerTests.cs b/tests/NATS.Server.Tests/NatsConfLexerTests.cs new file mode 100644 index 0000000..d664fc1 --- /dev/null +++ b/tests/NATS.Server.Tests/NatsConfLexerTests.cs @@ -0,0 +1,221 @@ +using NATS.Server.Configuration; + +namespace NATS.Server.Tests; + +public class NatsConfLexerTests +{ + [Fact] + public void Lex_SimpleKeyStringValue_ReturnsKeyAndString() + { + var tokens = NatsConfLexer.Tokenize("foo = \"bar\"").ToList(); + tokens[0].Type.ShouldBe(TokenType.Key); + tokens[0].Value.ShouldBe("foo"); + tokens[1].Type.ShouldBe(TokenType.String); + tokens[1].Value.ShouldBe("bar"); + tokens[2].Type.ShouldBe(TokenType.Eof); + } + + [Fact] + public void Lex_SingleQuotedString_ReturnsString() + { + var tokens = NatsConfLexer.Tokenize("foo = 'bar'").ToList(); + tokens[1].Type.ShouldBe(TokenType.String); + tokens[1].Value.ShouldBe("bar"); + } + + [Fact] + public void Lex_IntegerValue_ReturnsInteger() + { + var tokens = NatsConfLexer.Tokenize("port = 4222").ToList(); + tokens[0].Type.ShouldBe(TokenType.Key); + tokens[0].Value.ShouldBe("port"); + tokens[1].Type.ShouldBe(TokenType.Integer); + tokens[1].Value.ShouldBe("4222"); + } + + [Fact] + public void Lex_IntegerWithSuffix_ReturnsInteger() + { + var tokens = NatsConfLexer.Tokenize("size = 64mb").ToList(); + tokens[1].Type.ShouldBe(TokenType.Integer); + tokens[1].Value.ShouldBe("64mb"); + } + + [Fact] + public void Lex_BooleanValues_ReturnsBool() + { + foreach (var val in new[] { "true", "false", "yes", "no", "on", "off" }) + { + var tokens = NatsConfLexer.Tokenize($"flag = {val}").ToList(); + tokens[1].Type.ShouldBe(TokenType.Bool); + } + } + + [Fact] + public void Lex_FloatValue_ReturnsFloat() + { + var tokens = NatsConfLexer.Tokenize("rate = 2.5").ToList(); + tokens[1].Type.ShouldBe(TokenType.Float); + tokens[1].Value.ShouldBe("2.5"); + } + + [Fact] + public void Lex_NegativeNumber_ReturnsInteger() + { + var tokens = NatsConfLexer.Tokenize("offset = -10").ToList(); + tokens[1].Type.ShouldBe(TokenType.Integer); + tokens[1].Value.ShouldBe("-10"); + } + + [Fact] + public void Lex_DatetimeValue_ReturnsDatetime() + { + var tokens = NatsConfLexer.Tokenize("ts = 2024-01-15T10:30:00Z").ToList(); + tokens[1].Type.ShouldBe(TokenType.DateTime); + } + + [Fact] + public void Lex_HashComment_IsIgnored() + { + var tokens = NatsConfLexer.Tokenize("# this is a comment\nfoo = 1").ToList(); + var keys = tokens.Where(t => t.Type == TokenType.Key).ToList(); + keys.Count.ShouldBe(1); + keys[0].Value.ShouldBe("foo"); + } + + [Fact] + public void Lex_SlashComment_IsIgnored() + { + var tokens = NatsConfLexer.Tokenize("// comment\nfoo = 1").ToList(); + var keys = tokens.Where(t => t.Type == TokenType.Key).ToList(); + keys.Count.ShouldBe(1); + } + + [Fact] + public void Lex_MapBlock_ReturnsMapStartEnd() + { + var tokens = NatsConfLexer.Tokenize("auth { user: admin }").ToList(); + tokens[0].Type.ShouldBe(TokenType.Key); + tokens[0].Value.ShouldBe("auth"); + tokens[1].Type.ShouldBe(TokenType.MapStart); + tokens[2].Type.ShouldBe(TokenType.Key); + tokens[2].Value.ShouldBe("user"); + tokens[3].Type.ShouldBe(TokenType.String); + tokens[3].Value.ShouldBe("admin"); + tokens[4].Type.ShouldBe(TokenType.MapEnd); + } + + [Fact] + public void Lex_Array_ReturnsArrayStartEnd() + { + var tokens = NatsConfLexer.Tokenize("items = [1, 2, 3]").ToList(); + tokens[1].Type.ShouldBe(TokenType.ArrayStart); + tokens[2].Type.ShouldBe(TokenType.Integer); + tokens[2].Value.ShouldBe("1"); + tokens[5].Type.ShouldBe(TokenType.ArrayEnd); + } + + [Fact] + public void Lex_Variable_ReturnsVariable() + { + var tokens = NatsConfLexer.Tokenize("secret = $MY_VAR").ToList(); + tokens[1].Type.ShouldBe(TokenType.Variable); + tokens[1].Value.ShouldBe("MY_VAR"); + } + + [Fact] + public void Lex_Include_ReturnsInclude() + { + var tokens = NatsConfLexer.Tokenize("include \"auth.conf\"").ToList(); + tokens[0].Type.ShouldBe(TokenType.Include); + tokens[0].Value.ShouldBe("auth.conf"); + } + + [Fact] + public void Lex_EscapeSequences_AreProcessed() + { + var tokens = NatsConfLexer.Tokenize("msg = \"hello\\tworld\\n\"").ToList(); + tokens[1].Type.ShouldBe(TokenType.String); + tokens[1].Value.ShouldBe("hello\tworld\n"); + } + + [Fact] + public void Lex_HexEscape_IsProcessed() + { + var tokens = NatsConfLexer.Tokenize("val = \"\\x41\\x42\"").ToList(); + tokens[1].Value.ShouldBe("AB"); + } + + [Fact] + public void Lex_ColonSeparator_Works() + { + var tokens = NatsConfLexer.Tokenize("foo: bar").ToList(); + tokens[0].Type.ShouldBe(TokenType.Key); + tokens[1].Type.ShouldBe(TokenType.String); + } + + [Fact] + public void Lex_WhitespaceSeparator_Works() + { + var tokens = NatsConfLexer.Tokenize("foo bar").ToList(); + tokens[0].Type.ShouldBe(TokenType.Key); + tokens[1].Type.ShouldBe(TokenType.String); + } + + [Fact] + public void Lex_SemicolonTerminator_IsHandled() + { + var tokens = NatsConfLexer.Tokenize("foo = 1; bar = 2").ToList(); + var keys = tokens.Where(t => t.Type == TokenType.Key).ToList(); + keys.Count.ShouldBe(2); + } + + [Fact] + public void Lex_EmptyInput_ReturnsEof() + { + var tokens = NatsConfLexer.Tokenize("").ToList(); + tokens.Count.ShouldBe(1); + tokens[0].Type.ShouldBe(TokenType.Eof); + } + + [Fact] + public void Lex_BlockString_ReturnsString() + { + var input = "desc (\nthis is\na block\n)\n"; + var tokens = NatsConfLexer.Tokenize(input).ToList(); + tokens[0].Type.ShouldBe(TokenType.Key); + tokens[1].Type.ShouldBe(TokenType.String); + } + + [Fact] + public void Lex_IPAddress_ReturnsString() + { + var tokens = NatsConfLexer.Tokenize("host = 127.0.0.1").ToList(); + tokens[1].Type.ShouldBe(TokenType.String); + tokens[1].Value.ShouldBe("127.0.0.1"); + } + + [Fact] + public void Lex_TrackLineNumbers() + { + var tokens = NatsConfLexer.Tokenize("a = 1\nb = 2\nc = 3").ToList(); + tokens[0].Line.ShouldBe(1); // a + tokens[2].Line.ShouldBe(2); // b + tokens[4].Line.ShouldBe(3); // c + } + + [Fact] + public void Lex_UnterminatedString_ReturnsError() + { + var tokens = NatsConfLexer.Tokenize("foo = \"unterminated").ToList(); + tokens.ShouldContain(t => t.Type == TokenType.Error); + } + + [Fact] + public void Lex_StringStartingWithDigit_TreatedAsString() + { + var tokens = NatsConfLexer.Tokenize("foo = 3xyz").ToList(); + tokens[1].Type.ShouldBe(TokenType.String); + tokens[1].Value.ShouldBe("3xyz"); + } +} From f952e6afaba60280fba63436b13343a56beee02b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 04:23:27 -0500 Subject: [PATCH 2/9] feat: add new NatsOptions fields for Go config parity Adds 10 new fields to NatsOptions (ClientAdvertise, TraceVerbose, MaxTracedMsgLen, DisableSublistCache, ConnectErrorReports, ReconnectErrorReports, NoHeaderSupport, MaxClosedClients, NoSystemAccount, SystemAccount) plus InCmdLine tracking set. Moves MaxClosedClients from a private constant in NatsServer to a configurable option. --- src/NATS.Server/NatsOptions.cs | 15 +++++++++++++++ src/NATS.Server/NatsServer.cs | 3 +-- tests/NATS.Server.Tests/NatsOptionsTests.cs | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/NATS.Server/NatsOptions.cs b/src/NATS.Server/NatsOptions.cs index 8a9b56d..688a23f 100644 --- a/src/NATS.Server/NatsOptions.cs +++ b/src/NATS.Server/NatsOptions.cs @@ -71,6 +71,21 @@ public sealed class NatsOptions // Profiling (0 = disabled) public int ProfPort { get; set; } + // Extended options for Go parity + public string? ClientAdvertise { get; set; } + public bool TraceVerbose { get; set; } + public int MaxTracedMsgLen { get; set; } + public bool DisableSublistCache { get; set; } + public int ConnectErrorReports { get; set; } = 3600; + public int ReconnectErrorReports { get; set; } = 1; + public bool NoHeaderSupport { get; set; } + public int MaxClosedClients { get; set; } = 10_000; + public bool NoSystemAccount { get; set; } + public string? SystemAccount { get; set; } + + // Tracks which fields were set via CLI flags (for reload precedence) + public HashSet InCmdLine { get; } = []; + // TLS public string? TlsCert { get; set; } public string? TlsKey { get; set; } diff --git a/src/NATS.Server/NatsServer.cs b/src/NATS.Server/NatsServer.cs index 02a0734..e6ac3dd 100644 --- a/src/NATS.Server/NatsServer.cs +++ b/src/NATS.Server/NatsServer.cs @@ -20,7 +20,6 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable private readonly NatsOptions _options; private readonly ConcurrentDictionary _clients = new(); private readonly ConcurrentQueue _closedClients = new(); - private const int MaxClosedClients = 10_000; private readonly ServerInfo _serverInfo; private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; @@ -647,7 +646,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable }); // Cap closed clients list - while (_closedClients.Count > MaxClosedClients) + while (_closedClients.Count > _options.MaxClosedClients) _closedClients.TryDequeue(out _); var subList = client.Account?.SubList ?? _globalAccount.SubList; diff --git a/tests/NATS.Server.Tests/NatsOptionsTests.cs b/tests/NATS.Server.Tests/NatsOptionsTests.cs index 4e57769..e13d34c 100644 --- a/tests/NATS.Server.Tests/NatsOptionsTests.cs +++ b/tests/NATS.Server.Tests/NatsOptionsTests.cs @@ -14,4 +14,20 @@ public class NatsOptionsTests opts.LogSizeLimit.ShouldBe(0L); opts.Tags.ShouldBeNull(); } + + [Fact] + public void New_fields_have_correct_defaults() + { + var opts = new NatsOptions(); + opts.ClientAdvertise.ShouldBeNull(); + opts.TraceVerbose.ShouldBeFalse(); + opts.MaxTracedMsgLen.ShouldBe(0); + opts.DisableSublistCache.ShouldBeFalse(); + opts.ConnectErrorReports.ShouldBe(3600); + opts.ReconnectErrorReports.ShouldBe(1); + opts.NoHeaderSupport.ShouldBeFalse(); + opts.MaxClosedClients.ShouldBe(10_000); + opts.NoSystemAccount.ShouldBeFalse(); + opts.SystemAccount.ShouldBeNull(); + } } From ae043136a1f29331f806e01db72e88aa99bd9885 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 04:30:36 -0500 Subject: [PATCH 3/9] fix: address lexer code review findings (newline handling, emit cleanup, null guard) --- .../Configuration/NatsConfLexer.cs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/NATS.Server/Configuration/NatsConfLexer.cs b/src/NATS.Server/Configuration/NatsConfLexer.cs index 8f878fb..2a17cab 100644 --- a/src/NATS.Server/Configuration/NatsConfLexer.cs +++ b/src/NATS.Server/Configuration/NatsConfLexer.cs @@ -61,6 +61,7 @@ public sealed class NatsConfLexer public static IReadOnlyList Tokenize(string input) { + ArgumentNullException.ThrowIfNull(input); var lx = new NatsConfLexer(input); LexState? state = LexTop; while (state is not null) @@ -85,7 +86,17 @@ public sealed class NatsConfLexer private void Emit(TokenType type) { - var val = string.Concat(_stringParts) + _input[_start.._pos]; + string val; + if (_stringParts.Count > 0) + { + val = string.Concat(_stringParts) + _input[_start.._pos]; + _stringParts.Clear(); + } + else + { + val = _input[_start.._pos]; + } + var pos = _pos - _ilstart - val.Length; _items.Add(new Token(type, val, _line, pos)); _start = _pos; @@ -660,7 +671,7 @@ public sealed class NatsConfLexer private static LexState? LexKeyEnd(NatsConfLexer lx) { var r = lx.Next(); - if (char.IsWhiteSpace(r) && !IsNL(r)) + if (char.IsWhiteSpace(r)) { return LexSkip(lx, LexKeyEnd); } @@ -961,7 +972,7 @@ public sealed class NatsConfLexer private static LexState? LexMapKeyEnd(NatsConfLexer lx) { var r = lx.Next(); - if (char.IsWhiteSpace(r) && !IsNL(r)) + if (char.IsWhiteSpace(r)) { return LexSkip(lx, LexMapKeyEnd); } @@ -1239,13 +1250,13 @@ public sealed class NatsConfLexer private static LexState? LexStringBinary(NatsConfLexer lx) { var r1 = lx.Next(); - if (IsNL(r1)) + if (IsNL(r1) || r1 == Eof) { return lx.Errorf("Expected two hexadecimal digits after '\\x', but hit end of line"); } var r2 = lx.Next(); - if (IsNL(r2)) + if (IsNL(r2) || r2 == Eof) { return lx.Errorf("Expected two hexadecimal digits after '\\x', but hit end of line"); } From 9f66ef72c675f38fefe74cb8457635d669ef9b98 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 04:35:46 -0500 Subject: [PATCH 4/9] feat: add NATS config file parser (port of Go conf/parse.go) Implements NatsConfParser with Parse, ParseFile, and ParseFileWithDigest methods. Supports nested maps/arrays, variable resolution with block scoping and environment fallback, bcrypt password literals, integer suffix multipliers, include directives, and cycle detection. --- .../Configuration/NatsConfParser.cs | 404 ++++++++++++++++++ .../NATS.Server.Tests/NatsConfParserTests.cs | 184 ++++++++ 2 files changed, 588 insertions(+) create mode 100644 src/NATS.Server/Configuration/NatsConfParser.cs create mode 100644 tests/NATS.Server.Tests/NatsConfParserTests.cs diff --git a/src/NATS.Server/Configuration/NatsConfParser.cs b/src/NATS.Server/Configuration/NatsConfParser.cs new file mode 100644 index 0000000..77fa52a --- /dev/null +++ b/src/NATS.Server/Configuration/NatsConfParser.cs @@ -0,0 +1,404 @@ +// Port of Go conf/parse.go — recursive-descent parser for NATS config files. +// Reference: golang/nats-server/conf/parse.go + +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace NATS.Server.Configuration; + +/// +/// Parses NATS configuration data (tokenized by ) into +/// a Dictionary<string, object?> tree. Supports nested maps, arrays, +/// variable references (block-scoped + environment), include directives, bcrypt +/// password literals, and integer suffix multipliers. +/// +public static class NatsConfParser +{ + // Bcrypt hashes start with $2a$ or $2b$. The lexer consumes the leading '$' + // and emits a Variable token whose value begins with "2a$" or "2b$". + private const string BcryptPrefix2A = "2a$"; + private const string BcryptPrefix2B = "2b$"; + + /// + /// Parses a NATS configuration string into a dictionary. + /// + public static Dictionary Parse(string data) + { + var tokens = NatsConfLexer.Tokenize(data); + var state = new ParserState(tokens, baseDir: string.Empty); + state.Run(); + return state.Mapping; + } + + /// + /// Parses a NATS configuration file into a dictionary. + /// + public static Dictionary ParseFile(string filePath) + { + var data = File.ReadAllText(filePath); + var tokens = NatsConfLexer.Tokenize(data); + var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty; + var state = new ParserState(tokens, baseDir); + state.Run(); + return state.Mapping; + } + + /// + /// Parses a NATS configuration file and returns the parsed config plus a + /// SHA-256 digest of the raw file content formatted as "sha256:<hex>". + /// + public static (Dictionary Config, string Digest) ParseFileWithDigest(string filePath) + { + var rawBytes = File.ReadAllBytes(filePath); + var hashBytes = SHA256.HashData(rawBytes); + var digest = "sha256:" + Convert.ToHexStringLower(hashBytes); + + var data = Encoding.UTF8.GetString(rawBytes); + var tokens = NatsConfLexer.Tokenize(data); + var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty; + var state = new ParserState(tokens, baseDir); + state.Run(); + return (state.Mapping, digest); + } + + /// + /// Internal: parse an environment variable value by wrapping it in a synthetic + /// key-value assignment and parsing it. Shares the parent's env var cycle tracker. + /// + private static Dictionary ParseEnvValue(string value, HashSet envVarReferences) + { + var synthetic = $"pk={value}"; + var tokens = NatsConfLexer.Tokenize(synthetic); + var state = new ParserState(tokens, baseDir: string.Empty, envVarReferences); + state.Run(); + return state.Mapping; + } + + /// + /// Encapsulates the mutable parsing state: context stack, key stack, token cursor. + /// Mirrors the Go parser struct from conf/parse.go. + /// + private sealed class ParserState + { + private readonly IReadOnlyList _tokens; + private readonly string _baseDir; + private readonly HashSet _envVarReferences; + private int _pos; + + // The context stack holds either Dictionary (map) or List (array). + private readonly List _ctxs = new(4); + private object _ctx = null!; + + // Key stack for map assignments. + private readonly List _keys = new(4); + + public Dictionary Mapping { get; } = new(StringComparer.OrdinalIgnoreCase); + + public ParserState(IReadOnlyList tokens, string baseDir) + : this(tokens, baseDir, []) + { + } + + public ParserState(IReadOnlyList tokens, string baseDir, HashSet envVarReferences) + { + _tokens = tokens; + _baseDir = baseDir; + _envVarReferences = envVarReferences; + } + + public void Run() + { + PushContext(Mapping); + + Token prevToken = default; + while (true) + { + var token = Next(); + if (token.Type == TokenType.Eof) + { + // Allow a trailing '}' (JSON-like configs) — mirror Go behavior. + if (prevToken.Type == TokenType.Key && prevToken.Value != "}") + { + throw new FormatException($"Config is invalid at line {token.Line}:{token.Position}"); + } + + break; + } + + prevToken = token; + ProcessItem(token); + } + } + + private Token Next() + { + if (_pos >= _tokens.Count) + { + return new Token(TokenType.Eof, string.Empty, 0, 0); + } + + return _tokens[_pos++]; + } + + private void PushContext(object ctx) + { + _ctxs.Add(ctx); + _ctx = ctx; + } + + private object PopContext() + { + if (_ctxs.Count == 0) + { + throw new InvalidOperationException("BUG in parser, context stack empty"); + } + + var last = _ctxs[^1]; + _ctxs.RemoveAt(_ctxs.Count - 1); + _ctx = _ctxs[^1]; + return last; + } + + private void PushKey(string key) => _keys.Add(key); + + private string PopKey() + { + if (_keys.Count == 0) + { + throw new InvalidOperationException("BUG in parser, keys stack empty"); + } + + var last = _keys[^1]; + _keys.RemoveAt(_keys.Count - 1); + return last; + } + + private void SetValue(object? val) + { + // Array context: append the value. + if (_ctx is List array) + { + array.Add(val); + return; + } + + // Map context: pop the pending key and assign. + if (_ctx is Dictionary map) + { + var key = PopKey(); + map[key] = val; + } + } + + private void ProcessItem(Token token) + { + switch (token.Type) + { + case TokenType.Error: + throw new FormatException($"Parse error on line {token.Line}: '{token.Value}'"); + + case TokenType.Key: + PushKey(token.Value); + break; + + case TokenType.String: + SetValue(token.Value); + break; + + case TokenType.Bool: + SetValue(ParseBool(token.Value)); + break; + + case TokenType.Integer: + SetValue(ParseInteger(token.Value)); + break; + + case TokenType.Float: + SetValue(ParseFloat(token.Value)); + break; + + case TokenType.DateTime: + SetValue(DateTimeOffset.Parse(token.Value, CultureInfo.InvariantCulture)); + break; + + case TokenType.ArrayStart: + PushContext(new List()); + break; + + case TokenType.ArrayEnd: + { + var array = _ctx; + PopContext(); + SetValue(array); + break; + } + + case TokenType.MapStart: + PushContext(new Dictionary(StringComparer.OrdinalIgnoreCase)); + break; + + case TokenType.MapEnd: + SetValue(PopContext()); + break; + + case TokenType.Variable: + ResolveVariable(token); + break; + + case TokenType.Include: + ProcessInclude(token.Value); + break; + + case TokenType.Comment: + // Skip comments entirely. + break; + + case TokenType.Eof: + // Handled in the Run loop; should not reach here. + break; + + default: + throw new FormatException($"Unexpected token type {token.Type} on line {token.Line}"); + } + } + + private static bool ParseBool(string value) => + value.ToLowerInvariant() switch + { + "true" or "yes" or "on" => true, + "false" or "no" or "off" => false, + _ => throw new FormatException($"Expected boolean value, but got '{value}'"), + }; + + /// + /// Parses an integer token value, handling optional size suffixes + /// (k, kb, m, mb, g, gb, t, tb, etc.) exactly as the Go reference does. + /// + private static long ParseInteger(string value) + { + // Find where digits end and potential suffix begins. + var lastDigit = 0; + foreach (var c in value) + { + if (!char.IsDigit(c) && c != '-') + { + break; + } + + lastDigit++; + } + + var numStr = value[..lastDigit]; + if (!long.TryParse(numStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num)) + { + throw new FormatException($"Expected integer, but got '{value}'"); + } + + var suffix = value[lastDigit..].Trim().ToLowerInvariant(); + return suffix switch + { + "" => num, + "k" => num * 1000, + "kb" or "ki" or "kib" => num * 1024, + "m" => num * 1_000_000, + "mb" or "mi" or "mib" => num * 1024 * 1024, + "g" => num * 1_000_000_000, + "gb" or "gi" or "gib" => num * 1024 * 1024 * 1024, + "t" => num * 1_000_000_000_000, + "tb" or "ti" or "tib" => num * 1024L * 1024 * 1024 * 1024, + "p" => num * 1_000_000_000_000_000, + "pb" or "pi" or "pib" => num * 1024L * 1024 * 1024 * 1024 * 1024, + "e" => num * 1_000_000_000_000_000_000, + "eb" or "ei" or "eib" => num * 1024L * 1024 * 1024 * 1024 * 1024 * 1024, + _ => throw new FormatException($"Unknown integer suffix '{suffix}' in '{value}'"), + }; + } + + private static double ParseFloat(string value) + { + if (!double.TryParse(value, NumberStyles.Float | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var result)) + { + throw new FormatException($"Expected float, but got '{value}'"); + } + + return result; + } + + /// + /// Resolves a variable reference using block scoping: walks the context stack + /// top-down looking in map contexts, then falls back to environment variables. + /// Detects bcrypt password literals and reference cycles. + /// + private void ResolveVariable(Token token) + { + var varName = token.Value; + + // Special case: raw bcrypt strings ($2a$... or $2b$...). + // The lexer consumed the leading '$', so the variable value starts with "2a$" or "2b$". + if (varName.StartsWith(BcryptPrefix2A, StringComparison.Ordinal) || + varName.StartsWith(BcryptPrefix2B, StringComparison.Ordinal)) + { + SetValue("$" + varName); + return; + } + + // Walk context stack from top (innermost scope) to bottom (outermost). + for (var i = _ctxs.Count - 1; i >= 0; i--) + { + if (_ctxs[i] is Dictionary map && map.TryGetValue(varName, out var found)) + { + SetValue(found); + return; + } + } + + // Not found in any context map. Check environment variables. + // First, detect cycles. + if (!_envVarReferences.Add(varName)) + { + throw new FormatException($"Variable reference cycle for '{varName}'"); + } + + try + { + var envValue = Environment.GetEnvironmentVariable(varName); + if (envValue is not null) + { + // Parse the env value through the full parser to get correct typing + // (e.g., "42" becomes long 42, "true" becomes bool, etc.). + var subResult = ParseEnvValue(envValue, _envVarReferences); + if (subResult.TryGetValue("pk", out var parsedValue)) + { + SetValue(parsedValue); + return; + } + } + } + finally + { + _envVarReferences.Remove(varName); + } + + // Not found anywhere. + throw new FormatException( + $"Variable reference for '{varName}' on line {token.Line} can not be found"); + } + + /// + /// Processes an include directive by parsing the referenced file and merging + /// all its top-level keys into the current context. + /// + private void ProcessInclude(string includePath) + { + var fullPath = Path.Combine(_baseDir, includePath); + var includeResult = ParseFile(fullPath); + + foreach (var (key, value) in includeResult) + { + PushKey(key); + SetValue(value); + } + } + } +} diff --git a/tests/NATS.Server.Tests/NatsConfParserTests.cs b/tests/NATS.Server.Tests/NatsConfParserTests.cs new file mode 100644 index 0000000..bb7bb5d --- /dev/null +++ b/tests/NATS.Server.Tests/NatsConfParserTests.cs @@ -0,0 +1,184 @@ +using NATS.Server.Configuration; + +namespace NATS.Server.Tests; + +public class NatsConfParserTests +{ + [Fact] + public void Parse_SimpleTopLevel_ReturnsCorrectTypes() + { + var result = NatsConfParser.Parse("foo = '1'; bar = 2.2; baz = true; boo = 22"); + result["foo"].ShouldBe("1"); + result["bar"].ShouldBe(2.2); + result["baz"].ShouldBe(true); + result["boo"].ShouldBe(22L); + } + + [Fact] + public void Parse_Booleans_AllVariants() + { + foreach (var (input, expected) in new[] { + ("true", true), ("TRUE", true), ("yes", true), ("on", true), + ("false", false), ("FALSE", false), ("no", false), ("off", false) + }) + { + var result = NatsConfParser.Parse($"flag = {input}"); + result["flag"].ShouldBe(expected); + } + } + + [Fact] + public void Parse_IntegerWithSuffix_AppliesMultiplier() + { + var result = NatsConfParser.Parse("a = 1k; b = 2mb; c = 3gb; d = 4kb"); + result["a"].ShouldBe(1000L); + result["b"].ShouldBe(2L * 1024 * 1024); + result["c"].ShouldBe(3L * 1024 * 1024 * 1024); + result["d"].ShouldBe(4L * 1024); + } + + [Fact] + public void Parse_NestedMap_ReturnsDictionary() + { + var result = NatsConfParser.Parse("auth { user: admin, pass: secret }"); + var auth = result["auth"].ShouldBeOfType>(); + auth["user"].ShouldBe("admin"); + auth["pass"].ShouldBe("secret"); + } + + [Fact] + public void Parse_Array_ReturnsList() + { + var result = NatsConfParser.Parse("items = [1, 2, 3]"); + var items = result["items"].ShouldBeOfType>(); + items.Count.ShouldBe(3); + items[0].ShouldBe(1L); + } + + [Fact] + public void Parse_Variable_ResolvesFromContext() + { + var result = NatsConfParser.Parse("index = 22\nfoo = $index"); + result["foo"].ShouldBe(22L); + } + + [Fact] + public void Parse_NestedVariable_UsesBlockScope() + { + var input = "index = 22\nnest {\n index = 11\n foo = $index\n}\nbar = $index"; + var result = NatsConfParser.Parse(input); + var nest = result["nest"].ShouldBeOfType>(); + nest["foo"].ShouldBe(11L); + result["bar"].ShouldBe(22L); + } + + [Fact] + public void Parse_EnvironmentVariable_ResolvesFromEnv() + { + Environment.SetEnvironmentVariable("NATS_TEST_VAR_12345", "hello"); + try + { + var result = NatsConfParser.Parse("val = $NATS_TEST_VAR_12345"); + result["val"].ShouldBe("hello"); + } + finally + { + Environment.SetEnvironmentVariable("NATS_TEST_VAR_12345", null); + } + } + + [Fact] + public void Parse_UndefinedVariable_Throws() + { + Should.Throw(() => + NatsConfParser.Parse("val = $UNDEFINED_VAR_XYZZY_99999")); + } + + [Fact] + public void Parse_IncludeDirective_MergesFile() + { + var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + File.WriteAllText(Path.Combine(dir, "main.conf"), "port = 4222\ninclude \"sub.conf\""); + File.WriteAllText(Path.Combine(dir, "sub.conf"), "host = \"localhost\""); + var result = NatsConfParser.ParseFile(Path.Combine(dir, "main.conf")); + result["port"].ShouldBe(4222L); + result["host"].ShouldBe("localhost"); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Parse_MultipleKeySeparators_AllWork() + { + var r1 = NatsConfParser.Parse("a = 1"); + var r2 = NatsConfParser.Parse("a : 1"); + var r3 = NatsConfParser.Parse("a 1"); + r1["a"].ShouldBe(1L); + r2["a"].ShouldBe(1L); + r3["a"].ShouldBe(1L); + } + + [Fact] + public void Parse_ErrorOnInvalidInput_Throws() + { + Should.Throw(() => NatsConfParser.Parse("= invalid")); + } + + [Fact] + public void Parse_CommentsInsideBlocks_AreIgnored() + { + var input = "auth {\n # comment\n user: admin\n // another comment\n pass: secret\n}"; + var result = NatsConfParser.Parse(input); + var auth = result["auth"].ShouldBeOfType>(); + auth["user"].ShouldBe("admin"); + auth["pass"].ShouldBe("secret"); + } + + [Fact] + public void Parse_ArrayOfMaps_Works() + { + var input = "users = [\n { user: alice, pass: pw1 }\n { user: bob, pass: pw2 }\n]"; + var result = NatsConfParser.Parse(input); + var users = result["users"].ShouldBeOfType>(); + users.Count.ShouldBe(2); + var first = users[0].ShouldBeOfType>(); + first["user"].ShouldBe("alice"); + } + + [Fact] + public void Parse_BcryptPassword_HandledAsString() + { + var input = "pass = $2a$04$P/.bd.7unw9Ew7yWJqXsl.f4oNRLQGvadEL2YnqQXbbb.IVQajRdK"; + var result = NatsConfParser.Parse(input); + ((string)result["pass"]!).ShouldStartWith("$2a$"); + } + + [Fact] + public void ParseFile_WithDigest_ReturnsStableHash() + { + var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var conf = Path.Combine(dir, "test.conf"); + File.WriteAllText(conf, "port = 4222\nhost = \"localhost\""); + var (result, digest) = NatsConfParser.ParseFileWithDigest(conf); + result["port"].ShouldBe(4222L); + digest.ShouldStartWith("sha256:"); + digest.Length.ShouldBeGreaterThan(10); + + var (_, digest2) = NatsConfParser.ParseFileWithDigest(conf); + digest2.ShouldBe(digest); + } + finally + { + Directory.Delete(dir, true); + } + } +} From 5219f77f9b350424f01ab33a73d94585a6508cc3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 04:42:37 -0500 Subject: [PATCH 5/9] fix: add include depth limit, fix PopContext guard, add SetValue fallback - Add MaxIncludeDepth = 10 constant and thread _includeDepth through ParserState constructors, ProcessInclude, ParseFile (private overload), and ParseEnvValue to prevent StackOverflowException from recursive includes - Fix PopContext to check _ctxs.Count <= 1 instead of == 0 so the root context is never popped, replacing silent crash with clear InvalidOperationException - Add else throw in SetValue so unknown context types surface as bugs rather than silently dropping values --- .../Configuration/NatsConfParser.cs | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/src/NATS.Server/Configuration/NatsConfParser.cs b/src/NATS.Server/Configuration/NatsConfParser.cs index 77fa52a..e948bb8 100644 --- a/src/NATS.Server/Configuration/NatsConfParser.cs +++ b/src/NATS.Server/Configuration/NatsConfParser.cs @@ -20,6 +20,9 @@ public static class NatsConfParser private const string BcryptPrefix2A = "2a$"; private const string BcryptPrefix2B = "2b$"; + // Maximum nesting depth for include directives to prevent infinite recursion. + private const int MaxIncludeDepth = 10; + /// /// Parses a NATS configuration string into a dictionary. /// @@ -34,12 +37,15 @@ public static class NatsConfParser /// /// Parses a NATS configuration file into a dictionary. /// - public static Dictionary ParseFile(string filePath) + public static Dictionary ParseFile(string filePath) => + ParseFile(filePath, includeDepth: 0); + + private static Dictionary ParseFile(string filePath, int includeDepth) { var data = File.ReadAllText(filePath); var tokens = NatsConfLexer.Tokenize(data); var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty; - var state = new ParserState(tokens, baseDir); + var state = new ParserState(tokens, baseDir, [], includeDepth); state.Run(); return state.Mapping; } @@ -57,7 +63,7 @@ public static class NatsConfParser var data = Encoding.UTF8.GetString(rawBytes); var tokens = NatsConfLexer.Tokenize(data); var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty; - var state = new ParserState(tokens, baseDir); + var state = new ParserState(tokens, baseDir, [], includeDepth: 0); state.Run(); return (state.Mapping, digest); } @@ -66,11 +72,11 @@ public static class NatsConfParser /// Internal: parse an environment variable value by wrapping it in a synthetic /// key-value assignment and parsing it. Shares the parent's env var cycle tracker. /// - private static Dictionary ParseEnvValue(string value, HashSet envVarReferences) + private static Dictionary ParseEnvValue(string value, HashSet envVarReferences, int includeDepth) { var synthetic = $"pk={value}"; var tokens = NatsConfLexer.Tokenize(synthetic); - var state = new ParserState(tokens, baseDir: string.Empty, envVarReferences); + var state = new ParserState(tokens, baseDir: string.Empty, envVarReferences, includeDepth); state.Run(); return state.Mapping; } @@ -84,6 +90,7 @@ public static class NatsConfParser private readonly IReadOnlyList _tokens; private readonly string _baseDir; private readonly HashSet _envVarReferences; + private readonly int _includeDepth; private int _pos; // The context stack holds either Dictionary (map) or List (array). @@ -96,15 +103,16 @@ public static class NatsConfParser public Dictionary Mapping { get; } = new(StringComparer.OrdinalIgnoreCase); public ParserState(IReadOnlyList tokens, string baseDir) - : this(tokens, baseDir, []) + : this(tokens, baseDir, [], includeDepth: 0) { } - public ParserState(IReadOnlyList tokens, string baseDir, HashSet envVarReferences) + public ParserState(IReadOnlyList tokens, string baseDir, HashSet envVarReferences, int includeDepth) { _tokens = tokens; _baseDir = baseDir; _envVarReferences = envVarReferences; + _includeDepth = includeDepth; } public void Run() @@ -149,9 +157,9 @@ public static class NatsConfParser private object PopContext() { - if (_ctxs.Count == 0) + if (_ctxs.Count <= 1) { - throw new InvalidOperationException("BUG in parser, context stack empty"); + throw new InvalidOperationException("BUG in parser, context stack underflow"); } var last = _ctxs[^1]; @@ -188,7 +196,10 @@ public static class NatsConfParser { var key = PopKey(); map[key] = val; + return; } + + throw new InvalidOperationException($"BUG in parser, unexpected context type {_ctx?.GetType().Name ?? "null"}"); } private void ProcessItem(Token token) @@ -367,7 +378,7 @@ public static class NatsConfParser { // Parse the env value through the full parser to get correct typing // (e.g., "42" becomes long 42, "true" becomes bool, etc.). - var subResult = ParseEnvValue(envValue, _envVarReferences); + var subResult = ParseEnvValue(envValue, _envVarReferences, _includeDepth); if (subResult.TryGetValue("pk", out var parsedValue)) { SetValue(parsedValue); @@ -391,8 +402,14 @@ public static class NatsConfParser /// private void ProcessInclude(string includePath) { + if (_includeDepth >= MaxIncludeDepth) + { + throw new FormatException( + $"Include depth limit of {MaxIncludeDepth} exceeded while processing '{includePath}'"); + } + var fullPath = Path.Combine(_baseDir, includePath); - var includeResult = ParseFile(fullPath); + var includeResult = ParseFile(fullPath, _includeDepth + 1); foreach (var (key, value) in includeResult) { From 8a2ded8e48dffaef4a3dca8cecc8458bcba0ba01 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 04:47:54 -0500 Subject: [PATCH 6/9] feat: add config processor mapping parsed config to NatsOptions Port of Go server/opts.go processConfigFileLine switch. Maps parsed NATS config dictionaries to NatsOptions fields including: - Core options (port, host, server_name, limits, ping, write_deadline) - Logging (debug, trace, logfile, log rotation) - Authorization (single user, users array with permissions) - TLS (cert/key/ca, verify, pinned_certs, handshake_first) - Monitoring (http_port, https_port, http/https listen, base_path) - Lifecycle (lame_duck_duration/grace_period) - Server tags, file paths, system account options Includes error collection (not fail-fast), duration parsing (ms/s/m/h strings and numeric seconds), host:port listen parsing, and 56 tests covering all config sections plus validation edge cases. --- .../Configuration/ConfigProcessor.cs | 685 ++++++++++++++++++ .../NATS.Server.Tests/ConfigProcessorTests.cs | 504 +++++++++++++ .../NATS.Server.Tests.csproj | 4 + tests/NATS.Server.Tests/TestData/auth.conf | 11 + tests/NATS.Server.Tests/TestData/basic.conf | 19 + tests/NATS.Server.Tests/TestData/full.conf | 57 ++ tests/NATS.Server.Tests/TestData/tls.conf | 12 + 7 files changed, 1292 insertions(+) create mode 100644 src/NATS.Server/Configuration/ConfigProcessor.cs create mode 100644 tests/NATS.Server.Tests/ConfigProcessorTests.cs create mode 100644 tests/NATS.Server.Tests/TestData/auth.conf create mode 100644 tests/NATS.Server.Tests/TestData/basic.conf create mode 100644 tests/NATS.Server.Tests/TestData/full.conf create mode 100644 tests/NATS.Server.Tests/TestData/tls.conf diff --git a/src/NATS.Server/Configuration/ConfigProcessor.cs b/src/NATS.Server/Configuration/ConfigProcessor.cs new file mode 100644 index 0000000..88b36ae --- /dev/null +++ b/src/NATS.Server/Configuration/ConfigProcessor.cs @@ -0,0 +1,685 @@ +// Port of Go server/opts.go processConfigFileLine — maps parsed config dictionaries +// to NatsOptions. Reference: golang/nats-server/server/opts.go lines 1050-1400. + +using System.Globalization; +using System.Text.RegularExpressions; +using NATS.Server.Auth; + +namespace NATS.Server.Configuration; + +/// +/// Maps a parsed NATS configuration dictionary (produced by ) +/// into a fully populated instance. Collects all validation +/// errors rather than failing on the first one. +/// +public static class ConfigProcessor +{ + /// + /// Parses a configuration file and returns the populated options. + /// + public static NatsOptions ProcessConfigFile(string filePath) + { + var config = NatsConfParser.ParseFile(filePath); + var opts = new NatsOptions { ConfigFile = filePath }; + ApplyConfig(config, opts); + return opts; + } + + /// + /// Parses configuration text (not from a file) and returns the populated options. + /// + public static NatsOptions ProcessConfig(string configText) + { + var config = NatsConfParser.Parse(configText); + var opts = new NatsOptions(); + ApplyConfig(config, opts); + return opts; + } + + /// + /// Applies a parsed configuration dictionary to existing options. + /// Throws if any validation errors are collected. + /// + public static void ApplyConfig(Dictionary config, NatsOptions opts) + { + var errors = new List(); + + foreach (var (key, value) in config) + { + try + { + ProcessKey(key, value, opts, errors); + } + catch (Exception ex) + { + errors.Add($"Error processing '{key}': {ex.Message}"); + } + } + + if (errors.Count > 0) + { + throw new ConfigProcessorException("Configuration errors", errors); + } + } + + private static void ProcessKey(string key, object? value, NatsOptions opts, List errors) + { + // Keys are already case-insensitive from the parser (OrdinalIgnoreCase dictionaries), + // but we normalize here for the switch statement. + switch (key.ToLowerInvariant()) + { + case "listen": + ParseListen(value, opts); + break; + case "port": + opts.Port = ToInt(value); + break; + case "host" or "net": + opts.Host = ToString(value); + break; + case "server_name": + var name = ToString(value); + if (name.Contains(' ')) + errors.Add("server_name cannot contain spaces"); + else + opts.ServerName = name; + break; + case "client_advertise": + opts.ClientAdvertise = ToString(value); + break; + + // Logging + case "debug": + opts.Debug = ToBool(value); + break; + case "trace": + opts.Trace = ToBool(value); + break; + case "trace_verbose": + opts.TraceVerbose = ToBool(value); + if (opts.TraceVerbose) + opts.Trace = true; + break; + case "logtime": + opts.Logtime = ToBool(value); + break; + case "logtime_utc": + opts.LogtimeUTC = ToBool(value); + break; + case "logfile" or "log_file": + opts.LogFile = ToString(value); + break; + case "log_size_limit": + opts.LogSizeLimit = ToLong(value); + break; + case "log_max_num": + opts.LogMaxFiles = ToInt(value); + break; + case "syslog": + opts.Syslog = ToBool(value); + break; + case "remote_syslog": + opts.RemoteSyslog = ToString(value); + break; + + // Limits + case "max_payload": + opts.MaxPayload = ToInt(value); + break; + case "max_control_line": + opts.MaxControlLine = ToInt(value); + break; + case "max_connections" or "max_conn": + opts.MaxConnections = ToInt(value); + break; + case "max_pending": + opts.MaxPending = ToLong(value); + break; + case "max_subs" or "max_subscriptions": + opts.MaxSubs = ToInt(value); + break; + case "max_sub_tokens" or "max_subscription_tokens": + var tokens = ToInt(value); + if (tokens > 256) + errors.Add("max_sub_tokens cannot exceed 256"); + else + opts.MaxSubTokens = tokens; + break; + case "max_traced_msg_len": + opts.MaxTracedMsgLen = ToInt(value); + break; + case "max_closed_clients": + opts.MaxClosedClients = ToInt(value); + break; + case "disable_sublist_cache" or "no_sublist_cache": + opts.DisableSublistCache = ToBool(value); + break; + case "write_deadline": + opts.WriteDeadline = ParseDuration(value); + break; + + // Ping + case "ping_interval": + opts.PingInterval = ParseDuration(value); + break; + case "ping_max" or "ping_max_out": + opts.MaxPingsOut = ToInt(value); + break; + + // Monitoring + case "http_port" or "monitor_port": + opts.MonitorPort = ToInt(value); + break; + case "https_port": + opts.MonitorHttpsPort = ToInt(value); + break; + case "http": + ParseMonitorListen(value, opts, isHttps: false); + break; + case "https": + ParseMonitorListen(value, opts, isHttps: true); + break; + case "http_base_path": + opts.MonitorBasePath = ToString(value); + break; + + // Lifecycle + case "lame_duck_duration": + opts.LameDuckDuration = ParseDuration(value); + break; + case "lame_duck_grace_period": + opts.LameDuckGracePeriod = ParseDuration(value); + break; + + // Files + case "pidfile" or "pid_file": + opts.PidFile = ToString(value); + break; + case "ports_file_dir": + opts.PortsFileDir = ToString(value); + break; + + // Auth + case "authorization": + if (value is Dictionary authDict) + ParseAuthorization(authDict, opts, errors); + break; + case "no_auth_user": + opts.NoAuthUser = ToString(value); + break; + + // TLS + case "tls": + if (value is Dictionary tlsDict) + ParseTls(tlsDict, opts, errors); + break; + case "allow_non_tls": + opts.AllowNonTls = ToBool(value); + break; + + // Tags + case "server_tags": + if (value is Dictionary tagsDict) + ParseTags(tagsDict, opts); + break; + + // Profiling + case "prof_port": + opts.ProfPort = ToInt(value); + break; + + // System account + case "system_account": + opts.SystemAccount = ToString(value); + break; + case "no_system_account": + opts.NoSystemAccount = ToBool(value); + break; + case "no_header_support": + opts.NoHeaderSupport = ToBool(value); + break; + case "connect_error_reports": + opts.ConnectErrorReports = ToInt(value); + break; + case "reconnect_error_reports": + opts.ReconnectErrorReports = ToInt(value); + break; + + // Unknown keys silently ignored (cluster, jetstream, gateway, leafnode, etc.) + default: + break; + } + } + + // ─── Listen parsing ──────────────────────────────────────────── + + /// + /// Parses a "listen" value that can be: + /// + /// ":4222" — port only + /// "0.0.0.0:4222" — host + port + /// "4222" — bare number (port only) + /// 4222 — integer (port only) + /// + /// + private static void ParseListen(object? value, NatsOptions opts) + { + var (host, port) = ParseHostPort(value); + if (host is not null) + opts.Host = host; + if (port is not null) + opts.Port = port.Value; + } + + /// + /// Parses a monitor listen value. For "http" the port goes to MonitorPort; + /// for "https" the port goes to MonitorHttpsPort. + /// + private static void ParseMonitorListen(object? value, NatsOptions opts, bool isHttps) + { + var (host, port) = ParseHostPort(value); + if (host is not null) + opts.MonitorHost = host; + if (port is not null) + { + if (isHttps) + opts.MonitorHttpsPort = port.Value; + else + opts.MonitorPort = port.Value; + } + } + + /// + /// Shared host:port parsing logic. + /// + private static (string? Host, int? Port) ParseHostPort(object? value) + { + if (value is long l) + return (null, (int)l); + + var str = ToString(value); + + // Try bare integer + if (int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out var barePort)) + return (null, barePort); + + // Check for host:port + var colonIdx = str.LastIndexOf(':'); + if (colonIdx >= 0) + { + var hostPart = str[..colonIdx]; + var portPart = str[(colonIdx + 1)..]; + if (int.TryParse(portPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p)) + { + var host = hostPart.Length > 0 ? hostPart : null; + return (host, p); + } + } + + throw new FormatException($"Cannot parse listen value: '{str}'"); + } + + // ─── Duration parsing ────────────────────────────────────────── + + /// + /// Parses a duration value. Accepts: + /// + /// A string with unit suffix: "30s", "2m", "1h", "500ms" + /// A number (long/double) treated as seconds + /// + /// + internal static TimeSpan ParseDuration(object? value) + { + return value switch + { + long seconds => TimeSpan.FromSeconds(seconds), + double seconds => TimeSpan.FromSeconds(seconds), + string s => ParseDurationString(s), + _ => throw new FormatException($"Cannot parse duration from {value?.GetType().Name ?? "null"}"), + }; + } + + private static readonly Regex DurationPattern = new( + @"^(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static TimeSpan ParseDurationString(string s) + { + var match = DurationPattern.Match(s); + if (!match.Success) + throw new FormatException($"Cannot parse duration: '{s}'"); + + var amount = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var unit = match.Groups[2].Value.ToLowerInvariant(); + + return unit switch + { + "ms" => TimeSpan.FromMilliseconds(amount), + "s" => TimeSpan.FromSeconds(amount), + "m" => TimeSpan.FromMinutes(amount), + "h" => TimeSpan.FromHours(amount), + _ => throw new FormatException($"Unknown duration unit: '{unit}'"), + }; + } + + // ─── Authorization parsing ───────────────────────────────────── + + private static void ParseAuthorization(Dictionary dict, NatsOptions opts, List errors) + { + foreach (var (key, value) in dict) + { + switch (key.ToLowerInvariant()) + { + case "user" or "username": + opts.Username = ToString(value); + break; + case "pass" or "password": + opts.Password = ToString(value); + break; + case "token": + opts.Authorization = ToString(value); + break; + case "timeout": + opts.AuthTimeout = value switch + { + long l => TimeSpan.FromSeconds(l), + double d => TimeSpan.FromSeconds(d), + string s => ParseDuration(s), + _ => throw new FormatException($"Invalid auth timeout type: {value?.GetType().Name}"), + }; + break; + case "users": + if (value is List userList) + opts.Users = ParseUsers(userList, errors); + break; + default: + // Unknown auth keys silently ignored + break; + } + } + } + + private static List ParseUsers(List list, List errors) + { + var users = new List(); + foreach (var item in list) + { + if (item is not Dictionary userDict) + { + errors.Add("Expected user entry to be a map"); + continue; + } + + string? username = null; + string? password = null; + string? account = null; + Permissions? permissions = null; + + foreach (var (key, value) in userDict) + { + switch (key.ToLowerInvariant()) + { + case "user" or "username": + username = ToString(value); + break; + case "pass" or "password": + password = ToString(value); + break; + case "account": + account = ToString(value); + break; + case "permissions" or "permission": + if (value is Dictionary permDict) + permissions = ParsePermissions(permDict, errors); + break; + } + } + + if (username is null) + { + errors.Add("User entry missing 'user' field"); + continue; + } + + users.Add(new User + { + Username = username, + Password = password ?? string.Empty, + Account = account, + Permissions = permissions, + }); + } + + return users; + } + + private static Permissions ParsePermissions(Dictionary dict, List errors) + { + SubjectPermission? publish = null; + SubjectPermission? subscribe = null; + ResponsePermission? response = null; + + foreach (var (key, value) in dict) + { + switch (key.ToLowerInvariant()) + { + case "publish" or "pub": + publish = ParseSubjectPermission(value, errors); + break; + case "subscribe" or "sub": + subscribe = ParseSubjectPermission(value, errors); + break; + case "resp" or "response": + if (value is Dictionary respDict) + response = ParseResponsePermission(respDict); + break; + } + } + + return new Permissions + { + Publish = publish, + Subscribe = subscribe, + Response = response, + }; + } + + private static SubjectPermission? ParseSubjectPermission(object? value, List errors) + { + // Can be a simple list of strings (treated as allow) or a dict with allow/deny + if (value is Dictionary dict) + { + IReadOnlyList? allow = null; + IReadOnlyList? deny = null; + + foreach (var (key, v) in dict) + { + switch (key.ToLowerInvariant()) + { + case "allow": + allow = ToStringList(v); + break; + case "deny": + deny = ToStringList(v); + break; + } + } + + return new SubjectPermission { Allow = allow, Deny = deny }; + } + + if (value is List list) + { + return new SubjectPermission { Allow = ToStringList(list) }; + } + + if (value is string s) + { + return new SubjectPermission { Allow = [s] }; + } + + return null; + } + + private static ResponsePermission ParseResponsePermission(Dictionary dict) + { + var maxMsgs = 0; + var expires = TimeSpan.Zero; + + foreach (var (key, value) in dict) + { + switch (key.ToLowerInvariant()) + { + case "max_msgs" or "max": + maxMsgs = ToInt(value); + break; + case "expires" or "ttl": + expires = ParseDuration(value); + break; + } + } + + return new ResponsePermission { MaxMsgs = maxMsgs, Expires = expires }; + } + + // ─── TLS parsing ─────────────────────────────────────────────── + + private static void ParseTls(Dictionary dict, NatsOptions opts, List errors) + { + foreach (var (key, value) in dict) + { + switch (key.ToLowerInvariant()) + { + case "cert_file": + opts.TlsCert = ToString(value); + break; + case "key_file": + opts.TlsKey = ToString(value); + break; + case "ca_file": + opts.TlsCaCert = ToString(value); + break; + case "verify": + opts.TlsVerify = ToBool(value); + break; + case "verify_and_map": + var map = ToBool(value); + opts.TlsMap = map; + if (map) + opts.TlsVerify = true; + break; + case "timeout": + opts.TlsTimeout = value switch + { + long l => TimeSpan.FromSeconds(l), + double d => TimeSpan.FromSeconds(d), + string s => ParseDuration(s), + _ => throw new FormatException($"Invalid TLS timeout type: {value?.GetType().Name}"), + }; + break; + case "connection_rate_limit": + opts.TlsRateLimit = ToLong(value); + break; + case "pinned_certs": + if (value is List pinnedList) + { + var certs = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in pinnedList) + { + if (item is string s) + certs.Add(s.ToLowerInvariant()); + } + + opts.TlsPinnedCerts = certs; + } + + break; + case "handshake_first" or "first" or "immediate": + opts.TlsHandshakeFirst = ToBool(value); + break; + case "handshake_first_fallback": + opts.TlsHandshakeFirstFallback = ParseDuration(value); + break; + default: + // Unknown TLS keys silently ignored + break; + } + } + } + + // ─── Tags parsing ────────────────────────────────────────────── + + private static void ParseTags(Dictionary dict, NatsOptions opts) + { + var tags = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in dict) + { + tags[key] = ToString(value); + } + + opts.Tags = tags; + } + + // ─── Type conversion helpers ─────────────────────────────────── + + private static int ToInt(object? value) => value switch + { + long l => (int)l, + int i => i, + double d => (int)d, + string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) => i, + _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to int"), + }; + + private static long ToLong(object? value) => value switch + { + long l => l, + int i => i, + double d => (long)d, + string s when long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => l, + _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to long"), + }; + + private static bool ToBool(object? value) => value switch + { + bool b => b, + _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to bool"), + }; + + private static string ToString(object? value) => value switch + { + string s => s, + long l => l.ToString(CultureInfo.InvariantCulture), + _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to string"), + }; + + private static IReadOnlyList ToStringList(object? value) + { + if (value is List list) + { + var result = new List(list.Count); + foreach (var item in list) + { + if (item is string s) + result.Add(s); + } + + return result; + } + + if (value is string str) + return [str]; + + return []; + } +} + +/// +/// Thrown when one or more configuration validation errors are detected. +/// All errors are collected rather than failing on the first one. +/// +public sealed class ConfigProcessorException(string message, List errors) + : Exception(message) +{ + public IReadOnlyList Errors => errors; +} diff --git a/tests/NATS.Server.Tests/ConfigProcessorTests.cs b/tests/NATS.Server.Tests/ConfigProcessorTests.cs new file mode 100644 index 0000000..0ee2f39 --- /dev/null +++ b/tests/NATS.Server.Tests/ConfigProcessorTests.cs @@ -0,0 +1,504 @@ +using NATS.Server; +using NATS.Server.Configuration; + +namespace NATS.Server.Tests; + +public class ConfigProcessorTests +{ + private static string TestDataPath(string fileName) => + Path.Combine(AppContext.BaseDirectory, "TestData", fileName); + + // ─── Basic config ────────────────────────────────────────────── + + [Fact] + public void BasicConf_Port() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Port.ShouldBe(4222); + } + + [Fact] + public void BasicConf_Host() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Host.ShouldBe("0.0.0.0"); + } + + [Fact] + public void BasicConf_ServerName() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.ServerName.ShouldBe("test-server"); + } + + [Fact] + public void BasicConf_MaxPayload() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxPayload.ShouldBe(2 * 1024 * 1024); + } + + [Fact] + public void BasicConf_MaxConnections() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxConnections.ShouldBe(1000); + } + + [Fact] + public void BasicConf_Debug() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Debug.ShouldBeTrue(); + } + + [Fact] + public void BasicConf_Trace() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Trace.ShouldBeFalse(); + } + + [Fact] + public void BasicConf_PingInterval() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(30)); + } + + [Fact] + public void BasicConf_MaxPingsOut() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxPingsOut.ShouldBe(3); + } + + [Fact] + public void BasicConf_WriteDeadline() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void BasicConf_MaxSubs() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxSubs.ShouldBe(100); + } + + [Fact] + public void BasicConf_MaxSubTokens() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxSubTokens.ShouldBe(16); + } + + [Fact] + public void BasicConf_MaxControlLine() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxControlLine.ShouldBe(2048); + } + + [Fact] + public void BasicConf_MaxPending() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxPending.ShouldBe(32L * 1024 * 1024); + } + + [Fact] + public void BasicConf_LameDuckDuration() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.LameDuckDuration.ShouldBe(TimeSpan.FromSeconds(60)); + } + + [Fact] + public void BasicConf_LameDuckGracePeriod() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.LameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void BasicConf_MonitorPort() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MonitorPort.ShouldBe(8222); + } + + [Fact] + public void BasicConf_Logtime() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Logtime.ShouldBeTrue(); + opts.LogtimeUTC.ShouldBeFalse(); + } + + // ─── Auth config ─────────────────────────────────────────────── + + [Fact] + public void AuthConf_SimpleUser() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + opts.Username.ShouldBe("admin"); + opts.Password.ShouldBe("s3cret"); + } + + [Fact] + public void AuthConf_AuthTimeout() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void AuthConf_NoAuthUser() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + opts.NoAuthUser.ShouldBe("guest"); + } + + [Fact] + public void AuthConf_UsersArray() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + opts.Users.ShouldNotBeNull(); + opts.Users.Count.ShouldBe(2); + } + + [Fact] + public void AuthConf_AliceUser() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + var alice = opts.Users!.First(u => u.Username == "alice"); + alice.Password.ShouldBe("pw1"); + alice.Permissions.ShouldNotBeNull(); + alice.Permissions!.Publish.ShouldNotBeNull(); + alice.Permissions.Publish!.Allow.ShouldNotBeNull(); + alice.Permissions.Publish.Allow!.ShouldContain("foo.>"); + alice.Permissions.Subscribe.ShouldNotBeNull(); + alice.Permissions.Subscribe!.Allow.ShouldNotBeNull(); + alice.Permissions.Subscribe.Allow!.ShouldContain(">"); + } + + [Fact] + public void AuthConf_BobUser() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + var bob = opts.Users!.First(u => u.Username == "bob"); + bob.Password.ShouldBe("pw2"); + bob.Permissions.ShouldBeNull(); + } + + // ─── TLS config ──────────────────────────────────────────────── + + [Fact] + public void TlsConf_CertFiles() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsCert.ShouldBe("/path/to/cert.pem"); + opts.TlsKey.ShouldBe("/path/to/key.pem"); + opts.TlsCaCert.ShouldBe("/path/to/ca.pem"); + } + + [Fact] + public void TlsConf_Verify() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsVerify.ShouldBeTrue(); + opts.TlsMap.ShouldBeTrue(); + } + + [Fact] + public void TlsConf_Timeout() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(3)); + } + + [Fact] + public void TlsConf_RateLimit() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsRateLimit.ShouldBe(100); + } + + [Fact] + public void TlsConf_PinnedCerts() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsPinnedCerts.ShouldNotBeNull(); + opts.TlsPinnedCerts!.Count.ShouldBe(1); + opts.TlsPinnedCerts.ShouldContain("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + } + + [Fact] + public void TlsConf_HandshakeFirst() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsHandshakeFirst.ShouldBeTrue(); + } + + [Fact] + public void TlsConf_AllowNonTls() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.AllowNonTls.ShouldBeFalse(); + } + + // ─── Full config ─────────────────────────────────────────────── + + [Fact] + public void FullConf_CoreOptions() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.Port.ShouldBe(4222); + opts.Host.ShouldBe("0.0.0.0"); + opts.ServerName.ShouldBe("full-test"); + opts.ClientAdvertise.ShouldBe("nats://public.example.com:4222"); + } + + [Fact] + public void FullConf_Limits() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.MaxPayload.ShouldBe(1024 * 1024); + opts.MaxControlLine.ShouldBe(4096); + opts.MaxConnections.ShouldBe(65536); + opts.MaxPending.ShouldBe(64L * 1024 * 1024); + opts.MaxSubs.ShouldBe(0); + opts.MaxSubTokens.ShouldBe(0); + opts.MaxTracedMsgLen.ShouldBe(1024); + opts.DisableSublistCache.ShouldBeFalse(); + opts.MaxClosedClients.ShouldBe(5000); + } + + [Fact] + public void FullConf_Logging() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.Debug.ShouldBeFalse(); + opts.Trace.ShouldBeFalse(); + opts.TraceVerbose.ShouldBeFalse(); + opts.Logtime.ShouldBeTrue(); + opts.LogtimeUTC.ShouldBeFalse(); + opts.LogFile.ShouldBe("/var/log/nats.log"); + opts.LogSizeLimit.ShouldBe(100L * 1024 * 1024); + opts.LogMaxFiles.ShouldBe(5); + } + + [Fact] + public void FullConf_Monitoring() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.MonitorPort.ShouldBe(8222); + opts.MonitorBasePath.ShouldBe("/nats"); + } + + [Fact] + public void FullConf_Files() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.PidFile.ShouldBe("/var/run/nats.pid"); + opts.PortsFileDir.ShouldBe("/var/run"); + } + + [Fact] + public void FullConf_Lifecycle() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.LameDuckDuration.ShouldBe(TimeSpan.FromMinutes(2)); + opts.LameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(10)); + } + + [Fact] + public void FullConf_Tags() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.Tags.ShouldNotBeNull(); + opts.Tags!["region"].ShouldBe("us-east"); + opts.Tags["env"].ShouldBe("production"); + } + + [Fact] + public void FullConf_Auth() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.Username.ShouldBe("admin"); + opts.Password.ShouldBe("secret"); + opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2)); + } + + [Fact] + public void FullConf_Tls() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.TlsCert.ShouldBe("/path/to/cert.pem"); + opts.TlsKey.ShouldBe("/path/to/key.pem"); + opts.TlsCaCert.ShouldBe("/path/to/ca.pem"); + opts.TlsVerify.ShouldBeTrue(); + opts.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(2)); + opts.TlsHandshakeFirst.ShouldBeTrue(); + } + + // ─── Listen combined format ──────────────────────────────────── + + [Fact] + public void ListenCombined_HostAndPort() + { + var opts = ConfigProcessor.ProcessConfig("listen: \"10.0.0.1:5222\""); + opts.Host.ShouldBe("10.0.0.1"); + opts.Port.ShouldBe(5222); + } + + [Fact] + public void ListenCombined_PortOnly() + { + var opts = ConfigProcessor.ProcessConfig("listen: \":5222\""); + opts.Port.ShouldBe(5222); + } + + [Fact] + public void ListenCombined_BarePort() + { + var opts = ConfigProcessor.ProcessConfig("listen: 5222"); + opts.Port.ShouldBe(5222); + } + + // ─── HTTP combined format ────────────────────────────────────── + + [Fact] + public void HttpCombined_HostAndPort() + { + var opts = ConfigProcessor.ProcessConfig("http: \"10.0.0.1:8333\""); + opts.MonitorHost.ShouldBe("10.0.0.1"); + opts.MonitorPort.ShouldBe(8333); + } + + [Fact] + public void HttpsCombined_HostAndPort() + { + var opts = ConfigProcessor.ProcessConfig("https: \"10.0.0.1:8444\""); + opts.MonitorHost.ShouldBe("10.0.0.1"); + opts.MonitorHttpsPort.ShouldBe(8444); + } + + // ─── Duration as number ──────────────────────────────────────── + + [Fact] + public void DurationAsNumber_TreatedAsSeconds() + { + var opts = ConfigProcessor.ProcessConfig("ping_interval: 60"); + opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(60)); + } + + [Fact] + public void DurationAsString_Milliseconds() + { + var opts = ConfigProcessor.ProcessConfig("write_deadline: \"500ms\""); + opts.WriteDeadline.ShouldBe(TimeSpan.FromMilliseconds(500)); + } + + [Fact] + public void DurationAsString_Hours() + { + var opts = ConfigProcessor.ProcessConfig("ping_interval: \"1h\""); + opts.PingInterval.ShouldBe(TimeSpan.FromHours(1)); + } + + // ─── Unknown keys ────────────────────────────────────────────── + + [Fact] + public void UnknownKeys_SilentlyIgnored() + { + var opts = ConfigProcessor.ProcessConfig(""" + port: 4222 + cluster { name: "my-cluster" } + jetstream { store_dir: "/tmp/js" } + unknown_key: "whatever" + """); + opts.Port.ShouldBe(4222); + } + + // ─── Server name validation ──────────────────────────────────── + + [Fact] + public void ServerNameWithSpaces_ReportsError() + { + var ex = Should.Throw(() => + ConfigProcessor.ProcessConfig("server_name: \"my server\"")); + ex.Errors.ShouldContain(e => e.Contains("server_name cannot contain spaces")); + } + + // ─── Max sub tokens validation ───────────────────────────────── + + [Fact] + public void MaxSubTokens_ExceedsLimit_ReportsError() + { + var ex = Should.Throw(() => + ConfigProcessor.ProcessConfig("max_sub_tokens: 300")); + ex.Errors.ShouldContain(e => e.Contains("max_sub_tokens cannot exceed 256")); + } + + // ─── ProcessConfig from string ───────────────────────────────── + + [Fact] + public void ProcessConfig_FromString() + { + var opts = ConfigProcessor.ProcessConfig(""" + port: 9222 + host: "127.0.0.1" + debug: true + """); + opts.Port.ShouldBe(9222); + opts.Host.ShouldBe("127.0.0.1"); + opts.Debug.ShouldBeTrue(); + } + + // ─── TraceVerbose sets Trace ──────────────────────────────────── + + [Fact] + public void TraceVerbose_AlsoSetsTrace() + { + var opts = ConfigProcessor.ProcessConfig("trace_verbose: true"); + opts.TraceVerbose.ShouldBeTrue(); + opts.Trace.ShouldBeTrue(); + } + + // ─── Error collection (not fail-fast) ────────────────────────── + + [Fact] + public void MultipleErrors_AllCollected() + { + var ex = Should.Throw(() => + ConfigProcessor.ProcessConfig(""" + server_name: "bad name" + max_sub_tokens: 999 + """)); + ex.Errors.Count.ShouldBe(2); + ex.Errors.ShouldContain(e => e.Contains("server_name")); + ex.Errors.ShouldContain(e => e.Contains("max_sub_tokens")); + } + + // ─── ConfigFile path tracking ────────────────────────────────── + + [Fact] + public void ProcessConfigFile_SetsConfigFilePath() + { + var path = TestDataPath("basic.conf"); + var opts = ConfigProcessor.ProcessConfigFile(path); + opts.ConfigFile.ShouldBe(path); + } + + // ─── HasTls derived property ─────────────────────────────────── + + [Fact] + public void HasTls_TrueWhenCertAndKeySet() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.HasTls.ShouldBeTrue(); + } +} diff --git a/tests/NATS.Server.Tests/NATS.Server.Tests.csproj b/tests/NATS.Server.Tests/NATS.Server.Tests.csproj index 67f3fe4..503f4df 100644 --- a/tests/NATS.Server.Tests/NATS.Server.Tests.csproj +++ b/tests/NATS.Server.Tests/NATS.Server.Tests.csproj @@ -21,6 +21,10 @@ + + + + diff --git a/tests/NATS.Server.Tests/TestData/auth.conf b/tests/NATS.Server.Tests/TestData/auth.conf new file mode 100644 index 0000000..bb16a3e --- /dev/null +++ b/tests/NATS.Server.Tests/TestData/auth.conf @@ -0,0 +1,11 @@ +authorization { + user: admin + password: "s3cret" + timeout: 5 + + users = [ + { user: alice, password: "pw1", permissions: { publish: { allow: ["foo.>"] }, subscribe: { allow: [">"] } } } + { user: bob, password: "pw2" } + ] +} +no_auth_user: "guest" diff --git a/tests/NATS.Server.Tests/TestData/basic.conf b/tests/NATS.Server.Tests/TestData/basic.conf new file mode 100644 index 0000000..27ad7ef --- /dev/null +++ b/tests/NATS.Server.Tests/TestData/basic.conf @@ -0,0 +1,19 @@ +port: 4222 +host: "0.0.0.0" +server_name: "test-server" +max_payload: 2mb +max_connections: 1000 +debug: true +trace: false +logtime: true +logtime_utc: false +ping_interval: "30s" +ping_max: 3 +write_deadline: "5s" +max_subs: 100 +max_sub_tokens: 16 +max_control_line: 2048 +max_pending: 32mb +lame_duck_duration: "60s" +lame_duck_grace_period: "5s" +http_port: 8222 diff --git a/tests/NATS.Server.Tests/TestData/full.conf b/tests/NATS.Server.Tests/TestData/full.conf new file mode 100644 index 0000000..bb4d61e --- /dev/null +++ b/tests/NATS.Server.Tests/TestData/full.conf @@ -0,0 +1,57 @@ +# Full configuration with all supported options +port: 4222 +host: "0.0.0.0" +server_name: "full-test" +client_advertise: "nats://public.example.com:4222" + +max_payload: 1mb +max_control_line: 4096 +max_connections: 65536 +max_pending: 64mb +write_deadline: "10s" +max_subs: 0 +max_sub_tokens: 0 +max_traced_msg_len: 1024 +disable_sublist_cache: false +max_closed_clients: 5000 + +ping_interval: "2m" +ping_max: 2 + +debug: false +trace: false +trace_verbose: false +logtime: true +logtime_utc: false +logfile: "/var/log/nats.log" +log_size_limit: 100mb +log_max_num: 5 + +http_port: 8222 +http_base_path: "/nats" + +pidfile: "/var/run/nats.pid" +ports_file_dir: "/var/run" + +lame_duck_duration: "2m" +lame_duck_grace_period: "10s" + +server_tags { + region: "us-east" + env: "production" +} + +authorization { + user: admin + password: "secret" + timeout: 2 +} + +tls { + cert_file: "/path/to/cert.pem" + key_file: "/path/to/key.pem" + ca_file: "/path/to/ca.pem" + verify: true + timeout: 2 + handshake_first: true +} diff --git a/tests/NATS.Server.Tests/TestData/tls.conf b/tests/NATS.Server.Tests/TestData/tls.conf new file mode 100644 index 0000000..d8a99de --- /dev/null +++ b/tests/NATS.Server.Tests/TestData/tls.conf @@ -0,0 +1,12 @@ +tls { + cert_file: "/path/to/cert.pem" + key_file: "/path/to/key.pem" + ca_file: "/path/to/ca.pem" + verify: true + verify_and_map: true + timeout: 3 + connection_rate_limit: 100 + pinned_certs: ["abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"] + handshake_first: true +} +allow_non_tls: false From d21243bc8adca5a4c74bacdabaa192294e2e9b57 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 04:53:25 -0500 Subject: [PATCH 7/9] feat: add config reloader with diff, validate, and CLI merge Port of Go server/reload.go option interface and diffing logic. Compares NatsOptions property-by-property to detect changes, tags each with category flags (logging, auth, TLS, non-reloadable), validates that non-reloadable options (Host, Port, ServerName) are not changed at runtime, and provides MergeCliOverrides to ensure CLI flags always take precedence over config file values during hot reload. --- .../Configuration/ConfigReloader.cs | 341 ++++++++++++++++++ .../Configuration/IConfigChange.cs | 54 +++ tests/NATS.Server.Tests/ConfigReloadTests.cs | 89 +++++ 3 files changed, 484 insertions(+) create mode 100644 src/NATS.Server/Configuration/ConfigReloader.cs create mode 100644 src/NATS.Server/Configuration/IConfigChange.cs create mode 100644 tests/NATS.Server.Tests/ConfigReloadTests.cs diff --git a/src/NATS.Server/Configuration/ConfigReloader.cs b/src/NATS.Server/Configuration/ConfigReloader.cs new file mode 100644 index 0000000..3813323 --- /dev/null +++ b/src/NATS.Server/Configuration/ConfigReloader.cs @@ -0,0 +1,341 @@ +// Port of Go server/reload.go — config diffing, validation, and CLI override merging +// for hot reload support. Reference: golang/nats-server/server/reload.go. + +namespace NATS.Server.Configuration; + +/// +/// Provides static methods for comparing two instances, +/// validating that detected changes are reloadable, and merging CLI overrides +/// so that command-line flags always take precedence over config file values. +/// +public static class ConfigReloader +{ + // Non-reloadable options (match Go server — Host, Port, ServerName require restart) + private static readonly HashSet NonReloadable = ["Host", "Port", "ServerName"]; + + // Logging-related options + private static readonly HashSet LoggingOptions = + ["Debug", "Trace", "TraceVerbose", "Logtime", "LogtimeUTC", "LogFile", + "LogSizeLimit", "LogMaxFiles", "Syslog", "RemoteSyslog"]; + + // Auth-related options + private static readonly HashSet AuthOptions = + ["Username", "Password", "Authorization", "Users", "NKeys", + "NoAuthUser", "AuthTimeout"]; + + // TLS-related options + private static readonly HashSet TlsOptions = + ["TlsCert", "TlsKey", "TlsCaCert", "TlsVerify", "TlsMap", + "TlsTimeout", "TlsHandshakeFirst", "TlsHandshakeFirstFallback", + "AllowNonTls", "TlsRateLimit", "TlsPinnedCerts"]; + + /// + /// Compares two instances property by property and returns + /// a list of for every property that differs. Each change + /// is tagged with the appropriate category flags. + /// + public static List Diff(NatsOptions oldOpts, NatsOptions newOpts) + { + var changes = new List(); + + // Non-reloadable + CompareAndAdd(changes, "Host", oldOpts.Host, newOpts.Host); + CompareAndAdd(changes, "Port", oldOpts.Port, newOpts.Port); + CompareAndAdd(changes, "ServerName", oldOpts.ServerName, newOpts.ServerName); + + // Logging + CompareAndAdd(changes, "Debug", oldOpts.Debug, newOpts.Debug); + CompareAndAdd(changes, "Trace", oldOpts.Trace, newOpts.Trace); + CompareAndAdd(changes, "TraceVerbose", oldOpts.TraceVerbose, newOpts.TraceVerbose); + CompareAndAdd(changes, "Logtime", oldOpts.Logtime, newOpts.Logtime); + CompareAndAdd(changes, "LogtimeUTC", oldOpts.LogtimeUTC, newOpts.LogtimeUTC); + CompareAndAdd(changes, "LogFile", oldOpts.LogFile, newOpts.LogFile); + CompareAndAdd(changes, "LogSizeLimit", oldOpts.LogSizeLimit, newOpts.LogSizeLimit); + CompareAndAdd(changes, "LogMaxFiles", oldOpts.LogMaxFiles, newOpts.LogMaxFiles); + CompareAndAdd(changes, "Syslog", oldOpts.Syslog, newOpts.Syslog); + CompareAndAdd(changes, "RemoteSyslog", oldOpts.RemoteSyslog, newOpts.RemoteSyslog); + + // Auth + CompareAndAdd(changes, "Username", oldOpts.Username, newOpts.Username); + CompareAndAdd(changes, "Password", oldOpts.Password, newOpts.Password); + CompareAndAdd(changes, "Authorization", oldOpts.Authorization, newOpts.Authorization); + CompareCollectionAndAdd(changes, "Users", oldOpts.Users, newOpts.Users); + CompareCollectionAndAdd(changes, "NKeys", oldOpts.NKeys, newOpts.NKeys); + CompareAndAdd(changes, "NoAuthUser", oldOpts.NoAuthUser, newOpts.NoAuthUser); + CompareAndAdd(changes, "AuthTimeout", oldOpts.AuthTimeout, newOpts.AuthTimeout); + + // TLS + CompareAndAdd(changes, "TlsCert", oldOpts.TlsCert, newOpts.TlsCert); + CompareAndAdd(changes, "TlsKey", oldOpts.TlsKey, newOpts.TlsKey); + CompareAndAdd(changes, "TlsCaCert", oldOpts.TlsCaCert, newOpts.TlsCaCert); + CompareAndAdd(changes, "TlsVerify", oldOpts.TlsVerify, newOpts.TlsVerify); + CompareAndAdd(changes, "TlsMap", oldOpts.TlsMap, newOpts.TlsMap); + CompareAndAdd(changes, "TlsTimeout", oldOpts.TlsTimeout, newOpts.TlsTimeout); + CompareAndAdd(changes, "TlsHandshakeFirst", oldOpts.TlsHandshakeFirst, newOpts.TlsHandshakeFirst); + CompareAndAdd(changes, "TlsHandshakeFirstFallback", oldOpts.TlsHandshakeFirstFallback, newOpts.TlsHandshakeFirstFallback); + CompareAndAdd(changes, "AllowNonTls", oldOpts.AllowNonTls, newOpts.AllowNonTls); + CompareAndAdd(changes, "TlsRateLimit", oldOpts.TlsRateLimit, newOpts.TlsRateLimit); + CompareCollectionAndAdd(changes, "TlsPinnedCerts", oldOpts.TlsPinnedCerts, newOpts.TlsPinnedCerts); + + // Limits + CompareAndAdd(changes, "MaxConnections", oldOpts.MaxConnections, newOpts.MaxConnections); + CompareAndAdd(changes, "MaxPayload", oldOpts.MaxPayload, newOpts.MaxPayload); + CompareAndAdd(changes, "MaxPending", oldOpts.MaxPending, newOpts.MaxPending); + CompareAndAdd(changes, "WriteDeadline", oldOpts.WriteDeadline, newOpts.WriteDeadline); + CompareAndAdd(changes, "PingInterval", oldOpts.PingInterval, newOpts.PingInterval); + CompareAndAdd(changes, "MaxPingsOut", oldOpts.MaxPingsOut, newOpts.MaxPingsOut); + CompareAndAdd(changes, "MaxControlLine", oldOpts.MaxControlLine, newOpts.MaxControlLine); + CompareAndAdd(changes, "MaxSubs", oldOpts.MaxSubs, newOpts.MaxSubs); + CompareAndAdd(changes, "MaxSubTokens", oldOpts.MaxSubTokens, newOpts.MaxSubTokens); + CompareAndAdd(changes, "MaxTracedMsgLen", oldOpts.MaxTracedMsgLen, newOpts.MaxTracedMsgLen); + CompareAndAdd(changes, "MaxClosedClients", oldOpts.MaxClosedClients, newOpts.MaxClosedClients); + + // Misc + CompareCollectionAndAdd(changes, "Tags", oldOpts.Tags, newOpts.Tags); + CompareAndAdd(changes, "LameDuckDuration", oldOpts.LameDuckDuration, newOpts.LameDuckDuration); + CompareAndAdd(changes, "LameDuckGracePeriod", oldOpts.LameDuckGracePeriod, newOpts.LameDuckGracePeriod); + CompareAndAdd(changes, "ClientAdvertise", oldOpts.ClientAdvertise, newOpts.ClientAdvertise); + CompareAndAdd(changes, "DisableSublistCache", oldOpts.DisableSublistCache, newOpts.DisableSublistCache); + CompareAndAdd(changes, "ConnectErrorReports", oldOpts.ConnectErrorReports, newOpts.ConnectErrorReports); + CompareAndAdd(changes, "ReconnectErrorReports", oldOpts.ReconnectErrorReports, newOpts.ReconnectErrorReports); + CompareAndAdd(changes, "NoHeaderSupport", oldOpts.NoHeaderSupport, newOpts.NoHeaderSupport); + CompareAndAdd(changes, "NoSystemAccount", oldOpts.NoSystemAccount, newOpts.NoSystemAccount); + CompareAndAdd(changes, "SystemAccount", oldOpts.SystemAccount, newOpts.SystemAccount); + + return changes; + } + + /// + /// Validates a list of config changes and returns error messages for any + /// non-reloadable changes (properties that require a server restart). + /// + public static List Validate(List changes) + { + var errors = new List(); + foreach (var change in changes) + { + if (change.IsNonReloadable) + { + errors.Add($"Config reload: '{change.Name}' cannot be changed at runtime (requires restart)"); + } + } + + return errors; + } + + /// + /// Merges CLI overrides into a freshly-parsed config so that command-line flags + /// always take precedence. Only properties whose names appear in + /// are copied from to . + /// + public static void MergeCliOverrides(NatsOptions fromConfig, NatsOptions cliValues, HashSet cliFlags) + { + foreach (var flag in cliFlags) + { + switch (flag) + { + // Non-reloadable + case "Host": + fromConfig.Host = cliValues.Host; + break; + case "Port": + fromConfig.Port = cliValues.Port; + break; + case "ServerName": + fromConfig.ServerName = cliValues.ServerName; + break; + + // Logging + case "Debug": + fromConfig.Debug = cliValues.Debug; + break; + case "Trace": + fromConfig.Trace = cliValues.Trace; + break; + case "TraceVerbose": + fromConfig.TraceVerbose = cliValues.TraceVerbose; + break; + case "Logtime": + fromConfig.Logtime = cliValues.Logtime; + break; + case "LogtimeUTC": + fromConfig.LogtimeUTC = cliValues.LogtimeUTC; + break; + case "LogFile": + fromConfig.LogFile = cliValues.LogFile; + break; + case "LogSizeLimit": + fromConfig.LogSizeLimit = cliValues.LogSizeLimit; + break; + case "LogMaxFiles": + fromConfig.LogMaxFiles = cliValues.LogMaxFiles; + break; + case "Syslog": + fromConfig.Syslog = cliValues.Syslog; + break; + case "RemoteSyslog": + fromConfig.RemoteSyslog = cliValues.RemoteSyslog; + break; + + // Auth + case "Username": + fromConfig.Username = cliValues.Username; + break; + case "Password": + fromConfig.Password = cliValues.Password; + break; + case "Authorization": + fromConfig.Authorization = cliValues.Authorization; + break; + case "Users": + fromConfig.Users = cliValues.Users; + break; + case "NKeys": + fromConfig.NKeys = cliValues.NKeys; + break; + case "NoAuthUser": + fromConfig.NoAuthUser = cliValues.NoAuthUser; + break; + case "AuthTimeout": + fromConfig.AuthTimeout = cliValues.AuthTimeout; + break; + + // TLS + case "TlsCert": + fromConfig.TlsCert = cliValues.TlsCert; + break; + case "TlsKey": + fromConfig.TlsKey = cliValues.TlsKey; + break; + case "TlsCaCert": + fromConfig.TlsCaCert = cliValues.TlsCaCert; + break; + case "TlsVerify": + fromConfig.TlsVerify = cliValues.TlsVerify; + break; + case "TlsMap": + fromConfig.TlsMap = cliValues.TlsMap; + break; + case "TlsTimeout": + fromConfig.TlsTimeout = cliValues.TlsTimeout; + break; + case "TlsHandshakeFirst": + fromConfig.TlsHandshakeFirst = cliValues.TlsHandshakeFirst; + break; + case "TlsHandshakeFirstFallback": + fromConfig.TlsHandshakeFirstFallback = cliValues.TlsHandshakeFirstFallback; + break; + case "AllowNonTls": + fromConfig.AllowNonTls = cliValues.AllowNonTls; + break; + case "TlsRateLimit": + fromConfig.TlsRateLimit = cliValues.TlsRateLimit; + break; + case "TlsPinnedCerts": + fromConfig.TlsPinnedCerts = cliValues.TlsPinnedCerts; + break; + + // Limits + case "MaxConnections": + fromConfig.MaxConnections = cliValues.MaxConnections; + break; + case "MaxPayload": + fromConfig.MaxPayload = cliValues.MaxPayload; + break; + case "MaxPending": + fromConfig.MaxPending = cliValues.MaxPending; + break; + case "WriteDeadline": + fromConfig.WriteDeadline = cliValues.WriteDeadline; + break; + case "PingInterval": + fromConfig.PingInterval = cliValues.PingInterval; + break; + case "MaxPingsOut": + fromConfig.MaxPingsOut = cliValues.MaxPingsOut; + break; + case "MaxControlLine": + fromConfig.MaxControlLine = cliValues.MaxControlLine; + break; + case "MaxSubs": + fromConfig.MaxSubs = cliValues.MaxSubs; + break; + case "MaxSubTokens": + fromConfig.MaxSubTokens = cliValues.MaxSubTokens; + break; + case "MaxTracedMsgLen": + fromConfig.MaxTracedMsgLen = cliValues.MaxTracedMsgLen; + break; + case "MaxClosedClients": + fromConfig.MaxClosedClients = cliValues.MaxClosedClients; + break; + + // Misc + case "Tags": + fromConfig.Tags = cliValues.Tags; + break; + case "LameDuckDuration": + fromConfig.LameDuckDuration = cliValues.LameDuckDuration; + break; + case "LameDuckGracePeriod": + fromConfig.LameDuckGracePeriod = cliValues.LameDuckGracePeriod; + break; + case "ClientAdvertise": + fromConfig.ClientAdvertise = cliValues.ClientAdvertise; + break; + case "DisableSublistCache": + fromConfig.DisableSublistCache = cliValues.DisableSublistCache; + break; + case "ConnectErrorReports": + fromConfig.ConnectErrorReports = cliValues.ConnectErrorReports; + break; + case "ReconnectErrorReports": + fromConfig.ReconnectErrorReports = cliValues.ReconnectErrorReports; + break; + case "NoHeaderSupport": + fromConfig.NoHeaderSupport = cliValues.NoHeaderSupport; + break; + case "NoSystemAccount": + fromConfig.NoSystemAccount = cliValues.NoSystemAccount; + break; + case "SystemAccount": + fromConfig.SystemAccount = cliValues.SystemAccount; + break; + } + } + } + + // ─── Comparison helpers ───────────────────────────────────────── + + private static void CompareAndAdd(List changes, string name, T oldVal, T newVal) + { + if (!Equals(oldVal, newVal)) + { + changes.Add(new ConfigChange( + name, + isLoggingChange: LoggingOptions.Contains(name), + isAuthChange: AuthOptions.Contains(name), + isTlsChange: TlsOptions.Contains(name), + isNonReloadable: NonReloadable.Contains(name))); + } + } + + private static void CompareCollectionAndAdd(List changes, string name, T? oldVal, T? newVal) + where T : class + { + // For collections we compare by reference and null state. + // A change from null to non-null (or vice versa), or a different reference, counts as changed. + if (ReferenceEquals(oldVal, newVal)) + return; + + if (oldVal is null || newVal is null || !ReferenceEquals(oldVal, newVal)) + { + changes.Add(new ConfigChange( + name, + isLoggingChange: LoggingOptions.Contains(name), + isAuthChange: AuthOptions.Contains(name), + isTlsChange: TlsOptions.Contains(name), + isNonReloadable: NonReloadable.Contains(name))); + } + } +} diff --git a/src/NATS.Server/Configuration/IConfigChange.cs b/src/NATS.Server/Configuration/IConfigChange.cs new file mode 100644 index 0000000..538de12 --- /dev/null +++ b/src/NATS.Server/Configuration/IConfigChange.cs @@ -0,0 +1,54 @@ +// Port of Go server/reload.go option interface — represents a single detected +// configuration change with category flags for reload handling. +// Reference: golang/nats-server/server/reload.go lines 42-74. + +namespace NATS.Server.Configuration; + +/// +/// Represents a single detected configuration change during a hot reload. +/// Category flags indicate what kind of reload action is needed. +/// +public interface IConfigChange +{ + /// + /// The property name that changed (matches NatsOptions property name). + /// + string Name { get; } + + /// + /// Whether this change requires reloading the logger. + /// + bool IsLoggingChange { get; } + + /// + /// Whether this change requires reloading authorization. + /// + bool IsAuthChange { get; } + + /// + /// Whether this change requires reloading TLS configuration. + /// + bool IsTlsChange { get; } + + /// + /// Whether this option cannot be changed at runtime (requires restart). + /// + bool IsNonReloadable { get; } +} + +/// +/// Default implementation of using a primary constructor. +/// +public sealed class ConfigChange( + string name, + bool isLoggingChange = false, + bool isAuthChange = false, + bool isTlsChange = false, + bool isNonReloadable = false) : IConfigChange +{ + public string Name => name; + public bool IsLoggingChange => isLoggingChange; + public bool IsAuthChange => isAuthChange; + public bool IsTlsChange => isTlsChange; + public bool IsNonReloadable => isNonReloadable; +} diff --git a/tests/NATS.Server.Tests/ConfigReloadTests.cs b/tests/NATS.Server.Tests/ConfigReloadTests.cs new file mode 100644 index 0000000..a1d7fcd --- /dev/null +++ b/tests/NATS.Server.Tests/ConfigReloadTests.cs @@ -0,0 +1,89 @@ +using NATS.Server; +using NATS.Server.Auth; +using NATS.Server.Configuration; + +namespace NATS.Server.Tests; + +public class ConfigReloadTests +{ + [Fact] + public void Diff_NoChanges_ReturnsEmpty() + { + var old = new NatsOptions { Port = 4222, Debug = true }; + var @new = new NatsOptions { Port = 4222, Debug = true }; + var changes = ConfigReloader.Diff(old, @new); + changes.ShouldBeEmpty(); + } + + [Fact] + public void Diff_ReloadableChange_ReturnsChange() + { + var old = new NatsOptions { Debug = false }; + var @new = new NatsOptions { Debug = true }; + var changes = ConfigReloader.Diff(old, @new); + changes.Count.ShouldBe(1); + changes[0].Name.ShouldBe("Debug"); + changes[0].IsLoggingChange.ShouldBeTrue(); + } + + [Fact] + public void Diff_NonReloadableChange_ReturnsNonReloadableChange() + { + var old = new NatsOptions { Port = 4222 }; + var @new = new NatsOptions { Port = 5222 }; + var changes = ConfigReloader.Diff(old, @new); + changes.Count.ShouldBe(1); + changes[0].IsNonReloadable.ShouldBeTrue(); + } + + [Fact] + public void Diff_MultipleChanges_ReturnsAll() + { + var old = new NatsOptions { Debug = false, MaxPayload = 1024 }; + var @new = new NatsOptions { Debug = true, MaxPayload = 2048 }; + var changes = ConfigReloader.Diff(old, @new); + changes.Count.ShouldBe(2); + } + + [Fact] + public void Diff_AuthChange_MarkedCorrectly() + { + var old = new NatsOptions { Username = "alice" }; + var @new = new NatsOptions { Username = "bob" }; + var changes = ConfigReloader.Diff(old, @new); + changes[0].IsAuthChange.ShouldBeTrue(); + } + + [Fact] + public void Diff_TlsChange_MarkedCorrectly() + { + var old = new NatsOptions { TlsCert = "/old/cert.pem" }; + var @new = new NatsOptions { TlsCert = "/new/cert.pem" }; + var changes = ConfigReloader.Diff(old, @new); + changes[0].IsTlsChange.ShouldBeTrue(); + } + + [Fact] + public void Validate_NonReloadableChanges_ReturnsErrors() + { + var changes = new List + { + new ConfigChange("Port", isNonReloadable: true), + }; + var errors = ConfigReloader.Validate(changes); + errors.Count.ShouldBe(1); + errors[0].ShouldContain("Port"); + } + + [Fact] + public void MergeWithCli_CliOverridesConfig() + { + var fromConfig = new NatsOptions { Port = 5222, Debug = true }; + var cliFlags = new HashSet { "Port" }; + var cliValues = new NatsOptions { Port = 4222 }; + + ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags); + fromConfig.Port.ShouldBe(4222); // CLI wins + fromConfig.Debug.ShouldBeTrue(); // config value kept (not in CLI) + } +} From 684ee222ad3a1f5c89f3a291b1a6a3e6dc784f18 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 04:57:34 -0500 Subject: [PATCH 8/9] feat: integrate config file loading and SIGHUP hot reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up the config parsing infrastructure into the server: - NatsServer: add ReloadConfig() with digest-based change detection, diff/validate, CLI override preservation, and side-effect triggers - Program.cs: two-pass CLI parsing — load config file first, then apply CLI args on top with InCmdLine tracking for reload precedence - SIGHUP handler upgraded from stub warning to actual reload - Remove config file "not yet supported" warning from StartAsync - Add integration tests for config loading, CLI overrides, and reload validation --- src/NATS.Server.Host/Program.cs | 53 +++++- src/NATS.Server/NatsServer.cs | 176 +++++++++++++++++- .../ConfigIntegrationTests.cs | 76 ++++++++ 3 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 tests/NATS.Server.Tests/ConfigIntegrationTests.cs diff --git a/src/NATS.Server.Host/Program.cs b/src/NATS.Server.Host/Program.cs index 152e5f3..6eb467f 100644 --- a/src/NATS.Server.Host/Program.cs +++ b/src/NATS.Server.Host/Program.cs @@ -1,85 +1,124 @@ using NATS.Server; +using NATS.Server.Configuration; using Serilog; using Serilog.Sinks.SystemConsole.Themes; -var options = new NatsOptions(); +// First pass: scan args for -c flag to get config file path +string? configFile = null; +for (int i = 0; i < args.Length; i++) +{ + if (args[i] == "-c" && i + 1 < args.Length) + { + configFile = args[++i]; + break; + } +} -// Parse ALL CLI flags into NatsOptions first +// If config file specified, load it as the base options +var options = configFile != null + ? ConfigProcessor.ProcessConfigFile(configFile) + : new NatsOptions(); + +// Second pass: apply CLI args on top of config-loaded options, tracking InCmdLine for (int i = 0; i < args.Length; i++) { switch (args[i]) { case "-p" or "--port" when i + 1 < args.Length: options.Port = int.Parse(args[++i]); + options.InCmdLine.Add("Port"); break; case "-a" or "--addr" when i + 1 < args.Length: options.Host = args[++i]; + options.InCmdLine.Add("Host"); break; case "-n" or "--name" when i + 1 < args.Length: options.ServerName = args[++i]; + options.InCmdLine.Add("ServerName"); break; case "-m" or "--http_port" when i + 1 < args.Length: options.MonitorPort = int.Parse(args[++i]); + options.InCmdLine.Add("MonitorPort"); break; case "--http_base_path" when i + 1 < args.Length: options.MonitorBasePath = args[++i]; + options.InCmdLine.Add("MonitorBasePath"); break; case "--https_port" when i + 1 < args.Length: options.MonitorHttpsPort = int.Parse(args[++i]); + options.InCmdLine.Add("MonitorHttpsPort"); break; case "-c" when i + 1 < args.Length: - options.ConfigFile = args[++i]; + // Already handled in first pass; skip the value + i++; break; case "--pid" when i + 1 < args.Length: options.PidFile = args[++i]; + options.InCmdLine.Add("PidFile"); break; case "--ports_file_dir" when i + 1 < args.Length: options.PortsFileDir = args[++i]; + options.InCmdLine.Add("PortsFileDir"); break; case "--tls": break; case "--tlscert" when i + 1 < args.Length: options.TlsCert = args[++i]; + options.InCmdLine.Add("TlsCert"); break; case "--tlskey" when i + 1 < args.Length: options.TlsKey = args[++i]; + options.InCmdLine.Add("TlsKey"); break; case "--tlscacert" when i + 1 < args.Length: options.TlsCaCert = args[++i]; + options.InCmdLine.Add("TlsCaCert"); break; case "--tlsverify": options.TlsVerify = true; + options.InCmdLine.Add("TlsVerify"); break; case "-D" or "--debug": options.Debug = true; + options.InCmdLine.Add("Debug"); break; case "-V" or "-T" or "--trace": options.Trace = true; + options.InCmdLine.Add("Trace"); break; case "-DV": options.Debug = true; options.Trace = true; + options.InCmdLine.Add("Debug"); + options.InCmdLine.Add("Trace"); break; case "-l" or "--log" or "--log_file" when i + 1 < args.Length: options.LogFile = args[++i]; + options.InCmdLine.Add("LogFile"); break; case "--log_size_limit" when i + 1 < args.Length: options.LogSizeLimit = long.Parse(args[++i]); + options.InCmdLine.Add("LogSizeLimit"); break; case "--log_max_files" when i + 1 < args.Length: options.LogMaxFiles = int.Parse(args[++i]); + options.InCmdLine.Add("LogMaxFiles"); break; case "--logtime" when i + 1 < args.Length: options.Logtime = bool.Parse(args[++i]); + options.InCmdLine.Add("Logtime"); break; case "--logtime_utc": options.LogtimeUTC = true; + options.InCmdLine.Add("LogtimeUTC"); break; case "--syslog": options.Syslog = true; + options.InCmdLine.Add("Syslog"); break; case "--remote_syslog" when i + 1 < args.Length: options.RemoteSyslog = args[++i]; + options.InCmdLine.Add("RemoteSyslog"); break; } } @@ -136,6 +175,14 @@ Log.Logger = logConfig.CreateLogger(); using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger); using var server = new NatsServer(options, loggerFactory); +// Store CLI snapshot for reload precedence (CLI flags always win over config file) +if (configFile != null && options.InCmdLine.Count > 0) +{ + var cliSnapshot = new NatsOptions(); + ConfigReloader.MergeCliOverrides(cliSnapshot, options, options.InCmdLine); + server.SetCliSnapshot(cliSnapshot, options.InCmdLine); +} + // Register signal handlers server.HandleSignals(); diff --git a/src/NATS.Server/NatsServer.cs b/src/NATS.Server/NatsServer.cs index e6ac3dd..3b438f9 100644 --- a/src/NATS.Server/NatsServer.cs +++ b/src/NATS.Server/NatsServer.cs @@ -8,6 +8,7 @@ using System.Text; using Microsoft.Extensions.Logging; using NATS.NKeys; using NATS.Server.Auth; +using NATS.Server.Configuration; using NATS.Server.Monitoring; using NATS.Server.Protocol; using NATS.Server.Subscriptions; @@ -25,8 +26,13 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable private readonly ILoggerFactory _loggerFactory; private readonly ServerStats _stats = new(); private readonly TaskCompletionSource _listeningStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly AuthService _authService; + private AuthService _authService; private readonly ConcurrentDictionary _accounts = new(StringComparer.Ordinal); + + // Config reload state + private NatsOptions? _cliSnapshot; + private HashSet _cliFlags = []; + private string? _configDigest; private readonly Account _globalAccount; private readonly Account _systemAccount; private readonly SslServerAuthenticationOptions? _sslOptions; @@ -222,7 +228,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable _signalRegistrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx => { ctx.Cancel = true; - _logger.LogWarning("Trapped SIGHUP signal — config reload not yet supported"); + _logger.LogInformation("Trapped SIGHUP signal — reloading configuration"); + _ = Task.Run(() => ReloadConfig()); })); // SIGUSR1 and SIGUSR2 only on non-Windows @@ -284,6 +291,20 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable } BuildCachedInfo(); + + // Store initial config digest for reload change detection + if (options.ConfigFile != null) + { + try + { + var (_, digest) = NatsConfParser.ParseFileWithDigest(options.ConfigFile); + _configDigest = digest; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not compute initial config digest for {ConfigFile}", options.ConfigFile); + } + } } private void BuildCachedInfo() @@ -318,8 +339,6 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable _logger.LogInformation("Listening for client connections on {Host}:{Port}", _options.Host, _options.Port); // Warn about stub features - if (_options.ConfigFile != null) - _logger.LogWarning("Config file parsing not yet supported (file: {ConfigFile})", _options.ConfigFile); if (_options.ProfPort > 0) _logger.LogWarning("Profiling endpoint not yet supported (port: {ProfPort})", _options.ProfPort); @@ -716,6 +735,155 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable } } + /// + /// Stores the CLI snapshot and flags so that command-line overrides + /// always take precedence during config reload. + /// + public void SetCliSnapshot(NatsOptions cliSnapshot, HashSet cliFlags) + { + _cliSnapshot = cliSnapshot; + _cliFlags = cliFlags; + } + + /// + /// Reloads the configuration file, diffs against current options, validates + /// the changes, and applies reloadable settings. CLI overrides are preserved. + /// + public void ReloadConfig() + { + if (_options.ConfigFile == null) + { + _logger.LogWarning("No config file specified, cannot reload"); + return; + } + + try + { + var (newConfig, digest) = NatsConfParser.ParseFileWithDigest(_options.ConfigFile); + if (digest == _configDigest) + { + _logger.LogInformation("Config file unchanged, no reload needed"); + return; + } + + var newOpts = new NatsOptions { ConfigFile = _options.ConfigFile }; + ConfigProcessor.ApplyConfig(newConfig, newOpts); + + // CLI flags override config + if (_cliSnapshot != null) + ConfigReloader.MergeCliOverrides(newOpts, _cliSnapshot, _cliFlags); + + var changes = ConfigReloader.Diff(_options, newOpts); + var errors = ConfigReloader.Validate(changes); + if (errors.Count > 0) + { + foreach (var err in errors) + _logger.LogError("Config reload error: {Error}", err); + return; + } + + // Apply changes to running options + ApplyConfigChanges(changes, newOpts); + _configDigest = digest; + _logger.LogInformation("Config reloaded successfully ({Count} changes applied)", changes.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to reload config file: {ConfigFile}", _options.ConfigFile); + } + } + + private void ApplyConfigChanges(List changes, NatsOptions newOpts) + { + bool hasLoggingChanges = false; + bool hasAuthChanges = false; + + foreach (var change in changes) + { + if (change.IsLoggingChange) hasLoggingChanges = true; + if (change.IsAuthChange) hasAuthChanges = true; + } + + // Copy reloadable values from newOpts to _options + CopyReloadableOptions(newOpts); + + // Trigger side effects + if (hasLoggingChanges) + { + ReOpenLogFile?.Invoke(); + _logger.LogInformation("Logging configuration reloaded"); + } + + if (hasAuthChanges) + { + // Rebuild auth service with new options + _authService = AuthService.Build(_options); + _logger.LogInformation("Authorization configuration reloaded"); + } + } + + private void CopyReloadableOptions(NatsOptions newOpts) + { + // Logging + _options.Debug = newOpts.Debug; + _options.Trace = newOpts.Trace; + _options.TraceVerbose = newOpts.TraceVerbose; + _options.Logtime = newOpts.Logtime; + _options.LogtimeUTC = newOpts.LogtimeUTC; + _options.LogFile = newOpts.LogFile; + _options.LogSizeLimit = newOpts.LogSizeLimit; + _options.LogMaxFiles = newOpts.LogMaxFiles; + _options.Syslog = newOpts.Syslog; + _options.RemoteSyslog = newOpts.RemoteSyslog; + + // Auth + _options.Username = newOpts.Username; + _options.Password = newOpts.Password; + _options.Authorization = newOpts.Authorization; + _options.Users = newOpts.Users; + _options.NKeys = newOpts.NKeys; + _options.NoAuthUser = newOpts.NoAuthUser; + _options.AuthTimeout = newOpts.AuthTimeout; + + // Limits + _options.MaxConnections = newOpts.MaxConnections; + _options.MaxPayload = newOpts.MaxPayload; + _options.MaxPending = newOpts.MaxPending; + _options.WriteDeadline = newOpts.WriteDeadline; + _options.PingInterval = newOpts.PingInterval; + _options.MaxPingsOut = newOpts.MaxPingsOut; + _options.MaxControlLine = newOpts.MaxControlLine; + _options.MaxSubs = newOpts.MaxSubs; + _options.MaxSubTokens = newOpts.MaxSubTokens; + _options.MaxTracedMsgLen = newOpts.MaxTracedMsgLen; + _options.MaxClosedClients = newOpts.MaxClosedClients; + + // TLS + _options.TlsCert = newOpts.TlsCert; + _options.TlsKey = newOpts.TlsKey; + _options.TlsCaCert = newOpts.TlsCaCert; + _options.TlsVerify = newOpts.TlsVerify; + _options.TlsMap = newOpts.TlsMap; + _options.TlsTimeout = newOpts.TlsTimeout; + _options.TlsHandshakeFirst = newOpts.TlsHandshakeFirst; + _options.TlsHandshakeFirstFallback = newOpts.TlsHandshakeFirstFallback; + _options.AllowNonTls = newOpts.AllowNonTls; + _options.TlsRateLimit = newOpts.TlsRateLimit; + _options.TlsPinnedCerts = newOpts.TlsPinnedCerts; + + // Misc + _options.Tags = newOpts.Tags; + _options.LameDuckDuration = newOpts.LameDuckDuration; + _options.LameDuckGracePeriod = newOpts.LameDuckGracePeriod; + _options.ClientAdvertise = newOpts.ClientAdvertise; + _options.DisableSublistCache = newOpts.DisableSublistCache; + _options.ConnectErrorReports = newOpts.ConnectErrorReports; + _options.ReconnectErrorReports = newOpts.ReconnectErrorReports; + _options.NoHeaderSupport = newOpts.NoHeaderSupport; + _options.NoSystemAccount = newOpts.NoSystemAccount; + _options.SystemAccount = newOpts.SystemAccount; + } + public void Dispose() { if (!IsShuttingDown) diff --git a/tests/NATS.Server.Tests/ConfigIntegrationTests.cs b/tests/NATS.Server.Tests/ConfigIntegrationTests.cs new file mode 100644 index 0000000..92f25f3 --- /dev/null +++ b/tests/NATS.Server.Tests/ConfigIntegrationTests.cs @@ -0,0 +1,76 @@ +using NATS.Server.Configuration; + +namespace NATS.Server.Tests; + +public class ConfigIntegrationTests +{ + [Fact] + public void Server_WithConfigFile_LoadsOptionsFromFile() + { + var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var confPath = Path.Combine(dir, "test.conf"); + File.WriteAllText(confPath, "port: 14222\nmax_payload: 2mb\ndebug: true"); + + var opts = ConfigProcessor.ProcessConfigFile(confPath); + opts.Port.ShouldBe(14222); + opts.MaxPayload.ShouldBe(2 * 1024 * 1024); + opts.Debug.ShouldBeTrue(); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Server_CliOverridesConfig() + { + var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var confPath = Path.Combine(dir, "test.conf"); + File.WriteAllText(confPath, "port: 14222\ndebug: true"); + + var opts = ConfigProcessor.ProcessConfigFile(confPath); + opts.Port.ShouldBe(14222); + + // Simulate CLI override: user passed -p 5222 on command line + var cliSnapshot = new NatsOptions { Port = 5222 }; + var cliFlags = new HashSet { "Port" }; + ConfigReloader.MergeCliOverrides(opts, cliSnapshot, cliFlags); + + opts.Port.ShouldBe(5222); + opts.Debug.ShouldBeTrue(); // Config file value preserved + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Reload_ChangingPort_ReturnsError() + { + var oldOpts = new NatsOptions { Port = 4222 }; + var newOpts = new NatsOptions { Port = 5222 }; + var changes = ConfigReloader.Diff(oldOpts, newOpts); + var errors = ConfigReloader.Validate(changes); + errors.Count.ShouldBeGreaterThan(0); + errors[0].ShouldContain("Port"); + } + + [Fact] + public void Reload_ChangingDebug_IsValid() + { + var oldOpts = new NatsOptions { Debug = false }; + var newOpts = new NatsOptions { Debug = true }; + var changes = ConfigReloader.Diff(oldOpts, newOpts); + var errors = ConfigReloader.Validate(changes); + errors.ShouldBeEmpty(); + changes.ShouldContain(c => c.IsLoggingChange); + } +} From ad336167b9a96f3f84bff97bb597849dff1c6457 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 04:58:53 -0500 Subject: [PATCH 9/9] docs: update differences.md to reflect config parsing and hot reload implementation --- differences.md | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/differences.md b/differences.md index 829df24..05d61a5 100644 --- a/differences.md +++ b/differences.md @@ -12,7 +12,7 @@ |---------|:--:|:----:|-------| | NKey generation (server identity) | Y | Y | Ed25519 key pair via NATS.NKeys at startup | | System account setup | Y | Y | `$SYS` account created; no event publishing yet (stub) | -| Config file validation on startup | Y | Stub | `-c` flag parsed, `ConfigFile` stored, but no config parser | +| Config file validation on startup | Y | Y | Full config parsing with error collection via `ConfigProcessor` | | PID file writing | Y | Y | Written on startup, deleted on shutdown | | Profiling HTTP endpoint (`/debug/pprof`) | Y | Stub | `ProfPort` option exists but endpoint not implemented | | Ports file output | Y | Y | JSON ports file written to `PortsFileDir` on startup | @@ -42,7 +42,7 @@ | SIGTERM | Y | Y | `PosixSignalRegistration` triggers `ShutdownAsync()` | | SIGUSR1 (reopen logs) | Y | Y | SIGUSR1 handler calls ReOpenLogFile | | SIGUSR2 (lame duck mode) | Y | Y | Triggers `LameDuckShutdownAsync()` | -| SIGHUP (config reload) | Y | Stub | Signal registered, handler logs "not yet implemented" | +| SIGHUP (config reload) | Y | Y | Re-parses config, diffs options, applies reloadable subset; CLI flags preserved | | Windows Service integration | Y | N | | --- @@ -247,7 +247,7 @@ Go implements a sophisticated slow consumer detection system: | `-a/--addr` | Y | Y | | | `-n/--name` (ServerName) | Y | Y | | | `-m/--http_port` (monitoring) | Y | Y | | -| `-c` (config file) | Y | Stub | Flag parsed, stored in `ConfigFile`, no config parser | +| `-c` (config file) | Y | Y | Full config parsing: lexer → parser → processor; CLI args override config | | `-D/-V/-DV` (debug/trace) | Y | Y | `-D`/`--debug` for debug, `-V`/`-T`/`--trace` for trace, `-DV` for both | | `--tlscert/--tlskey/--tlscacert` | Y | Y | | | `--tlsverify` | Y | Y | | @@ -257,10 +257,10 @@ Go implements a sophisticated slow consumer detection system: ### Configuration System | Feature | Go | .NET | Notes | |---------|:--:|:----:|-------| -| Config file parsing | Y | N | Go has custom `conf` parser with includes | -| Hot reload (SIGHUP) | Y | N | | -| Config change detection | Y | N | Go tracks `inConfig`/`inCmdLine` origins | -| ~450 option fields | Y | ~62 | .NET covers core + debug/trace/logging/limits/tags options | +| Config file parsing | Y | Y | Custom NATS conf lexer/parser ported from Go; supports includes, variables, blocks | +| Hot reload (SIGHUP) | Y | Y | Reloads logging, auth, limits, TLS certs on SIGHUP; rejects non-reloadable changes | +| Config change detection | Y | Y | SHA256 digest comparison; `InCmdLine` tracks CLI flag precedence | +| ~450 option fields | Y | ~72 | .NET covers core + all single-server options; cluster/JetStream keys silently ignored | ### Missing Options Categories - ~~Logging options~~ — file logging, rotation, syslog, debug/trace, color, timestamps all implemented; only per-subsystem log control remains @@ -393,15 +393,13 @@ The following items from the original gap list have been implemented: - **Subscription statistics** — `Stats()`, `HasInterest()`, `NumInterest()`, etc. - **Per-account limits** — connection + subscription limits via `AccountConfig` - **Reply subject tracking** — `ResponseTracker` with TTL + max messages - -### Remaining High Priority -1. **Config file parsing** — needed for production deployment (CLI stub exists) -2. **Hot reload** — needed for zero-downtime config changes (SIGHUP stub exists) +- **Config file parsing** — custom lexer/parser ported from Go; supports includes, variables, nested blocks, size suffixes +- **Hot reload (SIGHUP)** — re-parses config, diffs changes, validates reloadable set, applies with CLI precedence ### Remaining Lower Priority -3. **Dynamic buffer sizing** — delegated to Pipe, less optimized for long-lived connections -4. **JWT authentication** — needed for operator mode -5. **OCSP support** — certificate revocation checking -6. **Subject mapping** — input→output subject transformation -7. **Windows Service integration** — needed for Windows deployment -8. **Per-subsystem log control** — granular log levels per component +1. **Dynamic buffer sizing** — delegated to Pipe, less optimized for long-lived connections +2. **JWT authentication** — needed for operator mode +3. **OCSP support** — certificate revocation checking +4. **Subject mapping** — input→output subject transformation +5. **Windows Service integration** — needed for Windows deployment +6. **Per-subsystem log control** — granular log levels per component