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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
184
tests/NATS.Server.Tests/NatsConfParserTests.cs
Normal file
184
tests/NATS.Server.Tests/NatsConfParserTests.cs
Normal file
@@ -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<Dictionary<string, object?>>();
|
||||||
|
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<List<object?>>();
|
||||||
|
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<Dictionary<string, object?>>();
|
||||||
|
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<FormatException>(() =>
|
||||||
|
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<FormatException>(() => 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<Dictionary<string, object?>>();
|
||||||
|
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<List<object?>>();
|
||||||
|
users.Count.ShouldBe(2);
|
||||||
|
var first = users[0].ShouldBeOfType<Dictionary<string, object?>>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user