From 9f66ef72c675f38fefe74cb8457635d669ef9b98 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 23 Feb 2026 04:35:46 -0500 Subject: [PATCH] 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); + } + } +}