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.
This commit is contained in:
404
src/NATS.Server/Configuration/NatsConfParser.cs
Normal file
404
src/NATS.Server/Configuration/NatsConfParser.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Parses NATS configuration data (tokenized by <see cref="NatsConfLexer"/>) into
|
||||
/// a <c>Dictionary<string, object?></c> tree. Supports nested maps, arrays,
|
||||
/// variable references (block-scoped + environment), include directives, bcrypt
|
||||
/// password literals, and integer suffix multipliers.
|
||||
/// </summary>
|
||||
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$";
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration string into a dictionary.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> Parse(string data)
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize(data);
|
||||
var state = new ParserState(tokens, baseDir: string.Empty);
|
||||
state.Run();
|
||||
return state.Mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration file into a dictionary.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration file and returns the parsed config plus a
|
||||
/// SHA-256 digest of the raw file content formatted as "sha256:<hex>".
|
||||
/// </summary>
|
||||
public static (Dictionary<string, object?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static Dictionary<string, object?> ParseEnvValue(string value, HashSet<string> envVarReferences)
|
||||
{
|
||||
var synthetic = $"pk={value}";
|
||||
var tokens = NatsConfLexer.Tokenize(synthetic);
|
||||
var state = new ParserState(tokens, baseDir: string.Empty, envVarReferences);
|
||||
state.Run();
|
||||
return state.Mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates the mutable parsing state: context stack, key stack, token cursor.
|
||||
/// Mirrors the Go <c>parser</c> struct from conf/parse.go.
|
||||
/// </summary>
|
||||
private sealed class ParserState
|
||||
{
|
||||
private readonly IReadOnlyList<Token> _tokens;
|
||||
private readonly string _baseDir;
|
||||
private readonly HashSet<string> _envVarReferences;
|
||||
private int _pos;
|
||||
|
||||
// The context stack holds either Dictionary<string, object?> (map) or List<object?> (array).
|
||||
private readonly List<object> _ctxs = new(4);
|
||||
private object _ctx = null!;
|
||||
|
||||
// Key stack for map assignments.
|
||||
private readonly List<string> _keys = new(4);
|
||||
|
||||
public Dictionary<string, object?> Mapping { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ParserState(IReadOnlyList<Token> tokens, string baseDir)
|
||||
: this(tokens, baseDir, [])
|
||||
{
|
||||
}
|
||||
|
||||
public ParserState(IReadOnlyList<Token> tokens, string baseDir, HashSet<string> 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<object?> array)
|
||||
{
|
||||
array.Add(val);
|
||||
return;
|
||||
}
|
||||
|
||||
// Map context: pop the pending key and assign.
|
||||
if (_ctx is Dictionary<string, object?> 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<object?>());
|
||||
break;
|
||||
|
||||
case TokenType.ArrayEnd:
|
||||
{
|
||||
var array = _ctx;
|
||||
PopContext();
|
||||
SetValue(array);
|
||||
break;
|
||||
}
|
||||
|
||||
case TokenType.MapStart:
|
||||
PushContext(new Dictionary<string, object?>(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}'"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses an integer token value, handling optional size suffixes
|
||||
/// (k, kb, m, mb, g, gb, t, tb, etc.) exactly as the Go reference does.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, object?> 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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes an include directive by parsing the referenced file and merging
|
||||
/// all its top-level keys into the current context.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user