Fix E2E test gaps and add comprehensive E2E + parity test suites

- Fix pull consumer fetch: send original stream subject in HMSG (not inbox)
  so NATS client distinguishes data messages from control messages
- Fix MaxAge expiry: add background timer in StreamManager for periodic pruning
- Fix JetStream wire format: Go-compatible anonymous objects with string enums,
  proper offset-based pagination for stream/consumer list APIs
- Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream)
- Add ~1000 parity tests across all subsystems (gaps closure)
- Update gap inventory docs to reflect implementation status
This commit is contained in:
Joseph Doherty
2026-03-12 14:09:23 -04:00
parent 79c1ee8776
commit c30e67a69d
226 changed files with 17801 additions and 709 deletions

View File

@@ -4,6 +4,8 @@
using System.Globalization;
using System.Text.RegularExpressions;
using NATS.Server.Auth;
using NATS.Server.JetStream;
using NATS.Server.Tls;
namespace NATS.Server.Configuration;
@@ -43,12 +45,13 @@ public static class ConfigProcessor
public static void ApplyConfig(Dictionary<string, object?> config, NatsOptions opts)
{
var errors = new List<string>();
var warnings = new List<string>();
foreach (var (key, value) in config)
{
try
{
ProcessKey(key, value, opts, errors);
ProcessKey(key, value, opts, errors, warnings);
}
catch (Exception ex)
{
@@ -58,11 +61,16 @@ public static class ConfigProcessor
if (errors.Count > 0)
{
throw new ConfigProcessorException("Configuration errors", errors);
throw new ConfigProcessorException("Configuration errors", errors, warnings);
}
}
private static void ProcessKey(string key, object? value, NatsOptions opts, List<string> errors)
private static void ProcessKey(
string key,
object? value,
NatsOptions opts,
List<string> errors,
List<string> warnings)
{
// Keys are already case-insensitive from the parser (OrdinalIgnoreCase dictionaries),
// but we normalize here for the switch statement.
@@ -277,8 +285,15 @@ public static class ConfigProcessor
ParseWebSocket(wsDict, opts, errors);
break;
// Unknown keys silently ignored (accounts, resolver, operator, etc.)
// Accounts block — each key is an account name containing users/limits
case "accounts":
if (value is Dictionary<string, object?> accountsDict)
ParseAccounts(accountsDict, opts, errors);
break;
// Unknown keys silently ignored (resolver, operator, etc.)
default:
warnings.Add(new UnknownConfigFieldWarning(key).Message);
break;
}
}
@@ -745,6 +760,9 @@ public static class ConfigProcessor
case "store_dir":
options.StoreDir = ToString(value);
break;
case "domain":
options.Domain = ToString(value);
break;
case "max_mem_store":
try
{
@@ -766,6 +784,68 @@ public static class ConfigProcessor
errors.Add($"Invalid jetstream.max_file_store: {ex.Message}");
}
break;
case "sync_interval":
try
{
options.SyncInterval = ParseDuration(value);
}
catch (Exception ex)
{
errors.Add($"Invalid jetstream.sync_interval: {ex.Message}");
}
break;
case "sync_always":
options.SyncAlways = ToBool(value);
break;
case "compress_ok":
options.CompressOk = ToBool(value);
break;
case "unique_tag":
options.UniqueTag = ToString(value);
break;
case "strict":
options.Strict = ToBool(value);
break;
case "max_ack_pending":
options.MaxAckPending = ToInt(value);
break;
case "memory_max_stream_bytes":
try
{
options.MemoryMaxStreamBytes = ParseByteSize(value);
}
catch (Exception ex)
{
errors.Add($"Invalid jetstream.memory_max_stream_bytes: {ex.Message}");
}
break;
case "store_max_stream_bytes":
try
{
options.StoreMaxStreamBytes = ParseByteSize(value);
}
catch (Exception ex)
{
errors.Add($"Invalid jetstream.store_max_stream_bytes: {ex.Message}");
}
break;
case "max_bytes_required":
options.MaxBytesRequired = ToBool(value);
break;
case "tiers":
if (value is Dictionary<string, object?> tiers)
{
foreach (var (tierName, rawTier) in tiers)
{
if (rawTier is Dictionary<string, object?> tierDict)
options.Tiers[tierName] = ParseJetStreamTier(tierName, tierDict, errors);
}
}
break;
}
}
@@ -773,6 +853,47 @@ public static class ConfigProcessor
return options;
}
private static JetStreamTier ParseJetStreamTier(string tierName, Dictionary<string, object?> dict, List<string> errors)
{
var tier = new JetStreamTier { Name = tierName };
foreach (var (key, value) in dict)
{
switch (key.ToLowerInvariant())
{
case "memory" or "max_memory":
try
{
tier.Memory = ParseByteSize(value);
}
catch (Exception ex)
{
errors.Add($"Invalid jetstream.tiers.{tierName}.memory: {ex.Message}");
}
break;
case "store" or "max_store":
try
{
tier.Store = ParseByteSize(value);
}
catch (Exception ex)
{
errors.Add($"Invalid jetstream.tiers.{tierName}.store: {ex.Message}");
}
break;
case "streams" or "max_streams":
tier.Streams = ToInt(value);
break;
case "consumers" or "max_consumers":
tier.Consumers = ToInt(value);
break;
}
}
return tier;
}
// ─── Authorization parsing ─────────────────────────────────────
private static void ParseAuthorization(Dictionary<string, object?> dict, NatsOptions opts, List<string> errors)
@@ -831,12 +952,80 @@ public static class ConfigProcessor
}
}
// ─── Accounts parsing ──────────────────────────────────────────
/// <summary>
/// Parses the top-level "accounts" block. Each key is an account name, and each
/// value is a dictionary that may contain "users" (array) and account-level limits.
/// Users are stamped with the account name and appended to opts.Users / opts.NKeys.
/// Go reference: opts.go — configureAccounts / parseAccounts.
/// </summary>
private static void ParseAccounts(Dictionary<string, object?> accountsDict, NatsOptions opts, List<string> errors)
{
opts.Accounts ??= new Dictionary<string, AccountConfig>();
foreach (var (accountName, accountValue) in accountsDict)
{
if (accountValue is not Dictionary<string, object?> acctDict)
{
errors.Add($"Expected account '{accountName}' value to be a map");
continue;
}
int maxConnections = 0;
int maxSubscriptions = 0;
List<object?>? userList = null;
foreach (var (key, value) in acctDict)
{
switch (key.ToLowerInvariant())
{
case "users":
if (value is List<object?> ul)
userList = ul;
break;
case "max_connections" or "max_conns":
maxConnections = ToInt(value);
break;
case "max_subscriptions" or "max_subs":
maxSubscriptions = ToInt(value);
break;
}
}
opts.Accounts[accountName] = new AccountConfig
{
MaxConnections = maxConnections,
MaxSubscriptions = maxSubscriptions,
};
if (userList is not null)
{
var (plainUsers, nkeyUsers) = ParseUsersAndNkeys(userList, errors, defaultAccount: accountName);
if (plainUsers.Count > 0)
{
var existing = opts.Users?.ToList() ?? [];
existing.AddRange(plainUsers);
opts.Users = existing;
}
if (nkeyUsers.Count > 0)
{
var existing = opts.NKeys?.ToList() ?? [];
existing.AddRange(nkeyUsers);
opts.NKeys = existing;
}
}
}
}
/// <summary>
/// Splits a users array into plain users and NKey users.
/// An entry with an "nkey" field is an NKey user; entries with "user" are plain users.
/// Go reference: opts.go — parseUsers (lines ~2500-2700).
/// </summary>
private static (List<User> PlainUsers, List<Auth.NKeyUser> NkeyUsers) ParseUsersAndNkeys(List<object?> list, List<string> errors)
private static (List<User> PlainUsers, List<Auth.NKeyUser> NkeyUsers) ParseUsersAndNkeys(List<object?> list, List<string> errors, string? defaultAccount = null)
{
var plainUsers = new List<User>();
var nkeyUsers = new List<Auth.NKeyUser>();
@@ -888,7 +1077,7 @@ public static class ConfigProcessor
{
Nkey = nkey,
Permissions = permissions,
Account = account,
Account = account ?? defaultAccount,
});
continue;
}
@@ -903,7 +1092,7 @@ public static class ConfigProcessor
{
Username = username,
Password = password ?? string.Empty,
Account = account,
Account = account ?? defaultAccount,
Permissions = permissions,
});
}
@@ -1087,6 +1276,9 @@ public static class ConfigProcessor
case "handshake_first_fallback":
opts.TlsHandshakeFirstFallback = ParseDuration(value);
break;
case "ocsp_peer":
ParseOcspPeer(value, opts, errors);
break;
default:
// Unknown TLS keys silently ignored
break;
@@ -1094,6 +1286,31 @@ public static class ConfigProcessor
}
}
private static void ParseOcspPeer(object? value, NatsOptions opts, List<string> errors)
{
switch (value)
{
case bool verify:
opts.OcspPeerVerify = verify;
return;
case Dictionary<string, object?> dict:
try
{
var cfg = OCSPPeerConfig.Parse(dict);
opts.OcspPeerVerify = cfg.Verify;
}
catch (FormatException ex)
{
errors.Add(ex.Message);
}
return;
default:
errors.Add($"expected map to define OCSP peer options, got [{value?.GetType().Name ?? "null"}]");
return;
}
}
// ─── Tags parsing ──────────────────────────────────────────────
private static void ParseTags(Dictionary<string, object?> dict, NatsOptions opts)
@@ -1431,8 +1648,28 @@ public static class ConfigProcessor
/// Thrown when one or more configuration validation errors are detected.
/// All errors are collected rather than failing on the first one.
/// </summary>
public sealed class ConfigProcessorException(string message, List<string> errors)
public sealed class ConfigProcessorException(string message, List<string> errors, List<string>? warnings = null)
: Exception(message)
{
public IReadOnlyList<string> Errors => errors;
public IReadOnlyList<string> Warnings => warnings ?? [];
}
/// <summary>
/// Represents a non-fatal configuration warning.
/// Go reference: configWarningErr.
/// </summary>
public class ConfigWarningException(string message, string? source = null) : Exception(message)
{
public string? SourceLocation { get; } = source;
}
/// <summary>
/// Warning used when an unknown config field is encountered.
/// Go reference: unknownConfigFieldErr.
/// </summary>
public sealed class UnknownConfigFieldWarning(string field, string? source = null)
: ConfigWarningException($"unknown field {field}", source)
{
public string Field { get; } = field;
}

View File

@@ -26,6 +26,115 @@ public sealed class GatewayOptions
/// </summary>
public sealed class RemoteGatewayOptions
{
private int _connAttempts;
public string? Name { get; set; }
public List<string> Urls { get; set; } = [];
public bool Implicit { get; set; }
public byte[]? Hash { get; set; }
public byte[]? OldHash { get; set; }
public string? TlsName { get; private set; }
public bool VarzUpdateUrls { get; set; }
/// <summary>
/// Deep clone helper for remote gateway options.
/// Go reference: RemoteGatewayOpts.clone.
/// </summary>
public RemoteGatewayOptions Clone()
{
return new RemoteGatewayOptions
{
Name = Name,
Urls = [.. Urls],
Implicit = Implicit,
Hash = Hash == null ? null : [.. Hash],
OldHash = OldHash == null ? null : [.. OldHash],
TlsName = TlsName,
VarzUpdateUrls = VarzUpdateUrls,
};
}
public int BumpConnAttempts() => Interlocked.Increment(ref _connAttempts);
public int GetConnAttempts() => Volatile.Read(ref _connAttempts);
public void ResetConnAttempts() => Interlocked.Exchange(ref _connAttempts, 0);
public bool IsImplicit() => Implicit;
public List<Uri> GetUrls(Random? random = null)
{
var urls = new List<Uri>();
foreach (var url in Urls)
{
if (TryNormalizeRemoteUrl(url, out var uri))
urls.Add(uri);
}
random ??= Random.Shared;
for (var i = urls.Count - 1; i > 0; i--)
{
var j = random.Next(i + 1);
(urls[i], urls[j]) = (urls[j], urls[i]);
}
return urls;
}
public List<string> GetUrlsAsStrings()
{
var result = new List<string>();
foreach (var uri in GetUrls())
result.Add($"{uri.Scheme}://{uri.Authority}");
return result;
}
public void UpdateUrls(IEnumerable<string> configuredUrls, IEnumerable<string> discoveredUrls)
{
var merged = new List<string>();
AddUrlsInternal(merged, configuredUrls);
AddUrlsInternal(merged, discoveredUrls);
Urls = merged;
}
public void SaveTlsHostname(string url)
{
if (TryNormalizeRemoteUrl(url, out var uri))
TlsName = uri.Host;
}
public void AddUrls(IEnumerable<string> discoveredUrls)
{
AddUrlsInternal(Urls, discoveredUrls);
}
private static void AddUrlsInternal(List<string> target, IEnumerable<string> urls)
{
var seen = new HashSet<string>(target, StringComparer.OrdinalIgnoreCase);
foreach (var url in urls)
{
if (!TryNormalizeRemoteUrl(url, out var uri))
continue;
var normalized = $"{uri.Scheme}://{uri.Authority}";
if (seen.Add(normalized))
target.Add(normalized);
}
}
private static bool TryNormalizeRemoteUrl(string? raw, out Uri uri)
{
uri = default!;
if (string.IsNullOrWhiteSpace(raw))
return false;
var normalized = raw.Contains("://", StringComparison.Ordinal) ? raw : $"nats://{raw}";
if (Uri.TryCreate(normalized, UriKind.Absolute, out var parsed) && parsed is not null)
{
uri = parsed;
return true;
}
return false;
}
}

View File

@@ -4,6 +4,11 @@ namespace NATS.Server.Configuration;
// Controls the lifecycle parameters for the JetStream subsystem.
public sealed class JetStreamOptions
{
// Go: server/jetstream.go constants (dynJetStreamConfig defaults)
public const string JetStreamStoreDir = "jetstream";
public const long JetStreamMaxStoreDefault = 1L << 40; // 1 TiB
public const long JetStreamMaxMemDefault = 256L * 1024 * 1024; // 256 MiB
/// <summary>
/// Directory where JetStream persists stream data.
/// Maps to Go's JetStreamConfig.StoreDir (jetstream.go:enableJetStream:430).
@@ -41,4 +46,64 @@ public sealed class JetStreamOptions
/// Maps to Go's Options.JetStreamDomain (opts.go).
/// </summary>
public string? Domain { get; set; }
/// <summary>
/// File-store sync interval.
/// Go reference: server/jetstream.go JetStreamConfig.SyncInterval.
/// </summary>
public TimeSpan SyncInterval { get; set; }
/// <summary>
/// Forces sync on each write when true.
/// Go reference: server/jetstream.go JetStreamConfig.SyncAlways.
/// </summary>
public bool SyncAlways { get; set; }
/// <summary>
/// Whether compression is allowed for JetStream replication/storage paths.
/// Go reference: server/jetstream.go JetStreamConfig.CompressOK.
/// </summary>
public bool CompressOk { get; set; }
/// <summary>
/// Unique placement tag used in clustered deployments.
/// Go reference: server/jetstream.go JetStreamConfig.UniqueTag.
/// </summary>
public string? UniqueTag { get; set; }
/// <summary>
/// Enables strict validation mode for JetStream.
/// Go reference: server/jetstream.go JetStreamConfig.Strict.
/// </summary>
public bool Strict { get; set; }
/// <summary>
/// Account-level maximum pending acknowledgements.
/// Go reference: server/jetstream.go JetStreamAccountLimits.MaxAckPending.
/// </summary>
public int MaxAckPending { get; set; }
/// <summary>
/// Maximum bytes allowed per memory-backed stream.
/// Go reference: server/jetstream.go JetStreamAccountLimits.MemoryMaxStreamBytes.
/// </summary>
public long MemoryMaxStreamBytes { get; set; }
/// <summary>
/// Maximum bytes allowed per file-backed stream.
/// Go reference: server/jetstream.go JetStreamAccountLimits.StoreMaxStreamBytes.
/// </summary>
public long StoreMaxStreamBytes { get; set; }
/// <summary>
/// When true, stream configs must specify explicit MaxBytes.
/// Go reference: server/jetstream.go JetStreamAccountLimits.MaxBytesRequired.
/// </summary>
public bool MaxBytesRequired { get; set; }
/// <summary>
/// Optional per-tier JetStream limits keyed by tier name.
/// Go reference: server/jetstream.go JetStreamAccountLimits tiers.
/// </summary>
public Dictionary<string, NATS.Server.JetStream.JetStreamTier> Tiers { get; set; } = new(StringComparer.Ordinal);
}

View File

@@ -17,6 +17,109 @@ public sealed class RemoteLeafOptions
/// <summary>Whether to not randomize URL order.</summary>
public bool DontRandomize { get; init; }
private int _urlIndex = -1;
private TimeSpan _connectDelay;
private Timer? _migrateTimer;
/// <summary>Last URL selected by <see cref="PickNextUrl"/>.</summary>
public string? CurrentUrl { get; private set; }
/// <summary>Saved TLS hostname for SNI usage on solicited connections.</summary>
public string? TlsName { get; private set; }
/// <summary>Username parsed from URL user-info fallback.</summary>
public string? Username { get; private set; }
/// <summary>Password parsed from URL user-info fallback.</summary>
public string? Password { get; private set; }
/// <summary>
/// Returns the next URL using round-robin order and updates <see cref="CurrentUrl"/>.
/// Go reference: leafnode.go leafNodeCfg.pickNextURL.
/// </summary>
public string PickNextUrl()
{
if (Urls.Count == 0)
throw new InvalidOperationException("No remote leaf URLs configured.");
var idx = Interlocked.Increment(ref _urlIndex);
var next = Urls[idx % Urls.Count];
CurrentUrl = next;
return next;
}
/// <summary>
/// Returns the current selected URL, or null if no URL has been selected yet.
/// Go reference: leafnode.go leafNodeCfg.getCurrentURL.
/// </summary>
public string? GetCurrentUrl() => CurrentUrl;
/// <summary>
/// Returns the currently configured reconnect/connect delay for this remote.
/// Go reference: leafnode.go leafNodeCfg.getConnectDelay.
/// </summary>
public TimeSpan GetConnectDelay() => _connectDelay;
/// <summary>
/// Sets reconnect/connect delay for this remote.
/// Go reference: leafnode.go leafNodeCfg.setConnectDelay.
/// </summary>
public void SetConnectDelay(TimeSpan delay) => _connectDelay = delay;
/// <summary>
/// Starts or replaces the JetStream migration timer callback for this remote leaf.
/// Go reference: leafnode.go leafNodeCfg.migrateTimer.
/// </summary>
public void StartMigrateTimer(TimerCallback callback, TimeSpan delay)
{
ArgumentNullException.ThrowIfNull(callback);
var timer = new Timer(callback, null, delay, Timeout.InfiniteTimeSpan);
var previous = Interlocked.Exchange(ref _migrateTimer, timer);
previous?.Dispose();
}
/// <summary>
/// Cancels the JetStream migration timer if active.
/// Go reference: leafnode.go leafNodeCfg.cancelMigrateTimer.
/// </summary>
public void CancelMigrateTimer()
{
var timer = Interlocked.Exchange(ref _migrateTimer, null);
timer?.Dispose();
}
/// <summary>
/// Saves TLS hostname from URL for future SNI usage.
/// Go reference: leafnode.go leafNodeCfg.saveTLSHostname.
/// </summary>
public void SaveTlsHostname(string url)
{
if (TryParseUrl(url, out var uri))
TlsName = uri.Host;
}
/// <summary>
/// Saves username/password from URL user info for fallback auth.
/// Go reference: leafnode.go leafNodeCfg.saveUserPassword.
/// </summary>
public void SaveUserPassword(string url)
{
if (!TryParseUrl(url, out var uri) || string.IsNullOrEmpty(uri.UserInfo))
return;
var parts = uri.UserInfo.Split(':', 2, StringSplitOptions.None);
Username = Uri.UnescapeDataString(parts[0]);
Password = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
}
private static bool TryParseUrl(string url, out Uri uri)
{
if (Uri.TryCreate(url, UriKind.Absolute, out uri!))
return true;
return Uri.TryCreate($"nats://{url}", UriKind.Absolute, out uri!);
}
}
public sealed class LeafNodeOptions

View File

@@ -1,6 +1,10 @@
// Port of Go conf/lex.go — state-machine tokenizer for NATS config files.
// Reference: golang/nats-server/conf/lex.go
using System.Buffers;
using System.Globalization;
using System.Text;
namespace NATS.Server.Configuration;
public sealed class NatsConfLexer
@@ -145,16 +149,23 @@ public sealed class NatsConfLexer
return Eof;
}
if (_input[_pos] == '\n')
var span = _input.AsSpan(_pos);
var status = Rune.DecodeFromUtf16(span, out var rune, out var consumed);
if (status != OperationStatus.Done || consumed <= 0)
{
consumed = 1;
rune = new Rune(_input[_pos]);
}
if (rune.Value == '\n')
{
_line++;
_lstart = _pos;
}
var c = _input[_pos];
_width = 1;
_pos += _width;
return c;
_width = consumed;
_pos += consumed;
return rune.IsBmp ? (char)rune.Value : '\uFFFD';
}
private void Ignore()
@@ -186,6 +197,20 @@ public sealed class NatsConfLexer
return null;
}
private LexState? Errorf(string format, params object?[] args)
{
if (args.Length == 0)
return Errorf(format);
var escapedArgs = new object?[args.Length];
for (var i = 0; i < args.Length; i++)
{
escapedArgs[i] = args[i] is char c ? EscapeSpecial(c) : args[i];
}
return Errorf(string.Format(CultureInfo.InvariantCulture, format, escapedArgs));
}
// --- Helper methods ---
private static bool IsWhitespace(char c) => c is '\t' or ' ';
@@ -1476,9 +1501,8 @@ public sealed class NatsConfLexer
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();
// Match Go behavior: emit comment body as a text token.
lx.Emit(TokenType.Text);
return lx.Pop();
}

View File

@@ -4,6 +4,7 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace NATS.Server.Configuration;
@@ -15,6 +16,7 @@ namespace NATS.Server.Configuration;
/// </summary>
public static class NatsConfParser
{
private const string BcryptPrefix = "2a$";
// 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$";
@@ -34,12 +36,24 @@ public static class NatsConfParser
return state.Mapping;
}
/// <summary>
/// Pedantic compatibility API (Go: ParseWithChecks).
/// Uses the same parser behavior as <see cref="Parse(string)"/>.
/// </summary>
public static Dictionary<string, object?> ParseWithChecks(string data) => Parse(data);
/// <summary>
/// Parses a NATS configuration file into a dictionary.
/// </summary>
public static Dictionary<string, object?> ParseFile(string filePath) =>
ParseFile(filePath, includeDepth: 0);
/// <summary>
/// Pedantic compatibility API (Go: ParseFileWithChecks).
/// Uses the same parser behavior as <see cref="ParseFile(string)"/>.
/// </summary>
public static Dictionary<string, object?> ParseFileWithChecks(string filePath) => ParseFile(filePath);
private static Dictionary<string, object?> ParseFile(string filePath, int includeDepth)
{
var data = File.ReadAllText(filePath);
@@ -68,6 +82,94 @@ public static class NatsConfParser
return (state.Mapping, digest);
}
/// <summary>
/// Pedantic compatibility API (Go: ParseFileWithChecksDigest).
/// </summary>
public static (Dictionary<string, object?> Config, string Digest) ParseFileWithChecksDigest(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, [], includeDepth: 0);
state.Run();
CleanupUsedEnvVars(state.Mapping);
var digest = ComputeConfigDigest(state.Mapping);
return (state.Mapping, digest);
}
// Go pedantic mode removes env-var wrapper nodes from parsed maps before digesting.
// The current parser does not persist env-wrapper nodes, so this remains a no-op hook.
private static void CleanupUsedEnvVars(Dictionary<string, object?> _) { }
private static string ComputeConfigDigest(Dictionary<string, object?> config)
{
using var ms = new MemoryStream();
using (var writer = new Utf8JsonWriter(ms))
{
WriteCanonicalJsonValue(writer, config);
writer.Flush();
}
var hashBytes = SHA256.HashData(ms.ToArray());
return "sha256:" + Convert.ToHexStringLower(hashBytes);
}
private static void WriteCanonicalJsonValue(Utf8JsonWriter writer, object? value)
{
switch (value)
{
case null:
writer.WriteNullValue();
return;
case Dictionary<string, object?> map:
writer.WriteStartObject();
foreach (var key in map.Keys.OrderBy(static k => k, StringComparer.Ordinal))
{
writer.WritePropertyName(key);
WriteCanonicalJsonValue(writer, map[key]);
}
writer.WriteEndObject();
return;
case List<object?> list:
writer.WriteStartArray();
foreach (var item in list)
WriteCanonicalJsonValue(writer, item);
writer.WriteEndArray();
return;
case IReadOnlyList<string> stringList:
writer.WriteStartArray();
foreach (var item in stringList)
writer.WriteStringValue(item);
writer.WriteEndArray();
return;
case bool b:
writer.WriteBooleanValue(b);
return;
case int i:
writer.WriteNumberValue(i);
return;
case long l:
writer.WriteNumberValue(l);
return;
case double d:
writer.WriteNumberValue(d);
return;
case float f:
writer.WriteNumberValue(f);
return;
case decimal dec:
writer.WriteNumberValue(dec);
return;
case string s:
writer.WriteStringValue(s);
return;
default:
JsonSerializer.Serialize(writer, value, value.GetType());
return;
}
}
/// <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.
@@ -99,6 +201,8 @@ public static class NatsConfParser
// Key stack for map assignments.
private readonly List<string> _keys = new(4);
// Pedantic-mode key token stack (Go parser field: ikeys).
private readonly List<Token> _itemKeys = new(4);
public Dictionary<string, object?> Mapping { get; } = new(StringComparer.OrdinalIgnoreCase);
@@ -182,6 +286,18 @@ public static class NatsConfParser
return last;
}
private void PushItemKey(Token token) => _itemKeys.Add(token);
private Token PopItemKey()
{
if (_itemKeys.Count == 0)
return default;
var last = _itemKeys[^1];
_itemKeys.RemoveAt(_itemKeys.Count - 1);
return last;
}
private void SetValue(object? val)
{
// Array context: append the value.
@@ -195,6 +311,7 @@ public static class NatsConfParser
if (_ctx is Dictionary<string, object?> map)
{
var key = PopKey();
_ = PopItemKey();
map[key] = val;
return;
}
@@ -211,6 +328,7 @@ public static class NatsConfParser
case TokenType.Key:
PushKey(token.Value);
PushItemKey(token);
break;
case TokenType.String:
@@ -262,6 +380,7 @@ public static class NatsConfParser
break;
case TokenType.Comment:
case TokenType.Text:
// Skip comments entirely.
break;
@@ -347,7 +466,8 @@ public static class NatsConfParser
// 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) ||
if (varName.StartsWith(BcryptPrefix, StringComparison.Ordinal) ||
varName.StartsWith(BcryptPrefix2A, StringComparison.Ordinal) ||
varName.StartsWith(BcryptPrefix2B, StringComparison.Ordinal))
{
SetValue("$" + varName);

View File

@@ -1,5 +1,7 @@
// Port of Go conf/lex.go token types.
using System.Text.Json;
namespace NATS.Server.Configuration;
public enum TokenType
@@ -7,6 +9,7 @@ public enum TokenType
Error,
Eof,
Key,
Text,
String,
Bool,
Integer,
@@ -22,3 +25,34 @@ public enum TokenType
}
public readonly record struct Token(TokenType Type, string Value, int Line, int Position);
/// <summary>
/// Pedantic token wrapper matching Go conf/parse.go token accessors.
/// </summary>
public sealed class PedanticToken
{
private readonly Token _item;
private readonly object? _value;
private readonly bool _usedVariable;
private readonly string _sourceFile;
public PedanticToken(Token item, object? value = null, bool usedVariable = false, string sourceFile = "")
{
_item = item;
_value = value;
_usedVariable = usedVariable;
_sourceFile = sourceFile ?? string.Empty;
}
public string MarshalJson() => JsonSerializer.Serialize(Value());
public object? Value() => _value ?? _item.Value;
public int Line() => _item.Line;
public bool IsUsedVariable() => _usedVariable;
public string SourceFile() => _sourceFile;
public int Position() => _item.Position;
}