// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/opts.go in the NATS server Go source.
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Net.Security;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Config;
namespace ZB.MOM.NatsNet.Server;
// Global flag for unknown field handling.
internal static class ConfigFlags
{
private static int _allowUnknownTopLevelField;
///
/// Sets whether unknown top-level config fields should be allowed.
/// Mirrors NoErrOnUnknownFields in opts.go.
///
public static void NoErrOnUnknownFields(bool noError) =>
Interlocked.Exchange(ref _allowUnknownTopLevelField, noError ? 1 : 0);
public static bool AllowUnknownTopLevelField =>
Interlocked.CompareExchange(ref _allowUnknownTopLevelField, 0, 0) != 0;
}
public sealed partial class ServerOptions
{
///
/// Toggles unknown top-level field handling for config parsing.
/// Mirrors NoErrOnUnknownFields in opts.go.
///
public static void NoErrOnUnknownFields(bool noError) => ConfigFlags.NoErrOnUnknownFields(noError);
///
/// Snapshot of command-line flags, populated during .
/// Mirrors FlagSnapshot in opts.go.
///
public static ServerOptions? FlagSnapshot { get; internal set; }
///
/// Deep-copies this instance.
/// Mirrors Options.Clone() in opts.go.
///
public ServerOptions Clone()
{
// Start with a shallow memberwise clone.
var clone = (ServerOptions)MemberwiseClone();
// Deep-copy reference types that need isolation.
if (Routes.Count > 0)
clone.Routes = Routes.Select(u => new Uri(u.ToString())).ToList();
clone.Cluster = CloneClusterOpts(Cluster);
clone.Gateway = CloneGatewayOpts(Gateway);
clone.LeafNode = CloneLeafNodeOpts(LeafNode);
clone.Websocket = CloneWebsocketOpts(Websocket);
clone.Mqtt = CloneMqttOpts(Mqtt);
clone.Tags = [.. Tags];
clone.Metadata = new Dictionary(Metadata);
clone.TrustedKeys = [.. TrustedKeys];
clone.JsAccDefaultDomain = new Dictionary(JsAccDefaultDomain);
clone.InConfig = new Dictionary(InConfig);
clone.InCmdLine = new Dictionary(InCmdLine);
clone.OperatorJwt = [.. OperatorJwt];
clone.ResolverPreloads = new Dictionary(ResolverPreloads);
clone.ResolverPinnedAccounts = [.. ResolverPinnedAccounts];
return clone;
}
///
/// Returns the SHA-256 digest of the configuration.
/// Mirrors Options.ConfigDigest() in opts.go.
///
public string ConfigDigest() => ConfigDigestValue;
// -------------------------------------------------------------------------
// Merge / Baseline
// -------------------------------------------------------------------------
///
/// Merges file-based options with command-line flag options.
/// Flag options override file options where set.
/// Mirrors MergeOptions in opts.go.
///
public static ServerOptions MergeOptions(ServerOptions? fileOpts, ServerOptions? flagOpts)
{
if (fileOpts == null) return flagOpts ?? new ServerOptions();
if (flagOpts == null) return fileOpts;
var opts = fileOpts.Clone();
if (flagOpts.Port != 0) opts.Port = flagOpts.Port;
if (!string.IsNullOrEmpty(flagOpts.Host)) opts.Host = flagOpts.Host;
if (flagOpts.DontListen) opts.DontListen = true;
if (!string.IsNullOrEmpty(flagOpts.ClientAdvertise)) opts.ClientAdvertise = flagOpts.ClientAdvertise;
if (!string.IsNullOrEmpty(flagOpts.Username)) opts.Username = flagOpts.Username;
if (!string.IsNullOrEmpty(flagOpts.Password)) opts.Password = flagOpts.Password;
if (!string.IsNullOrEmpty(flagOpts.Authorization)) opts.Authorization = flagOpts.Authorization;
if (flagOpts.HttpPort != 0) opts.HttpPort = flagOpts.HttpPort;
if (!string.IsNullOrEmpty(flagOpts.HttpBasePath)) opts.HttpBasePath = flagOpts.HttpBasePath;
if (flagOpts.Debug) opts.Debug = true;
if (flagOpts.Trace) opts.Trace = true;
if (flagOpts.Logtime) opts.Logtime = true;
if (!string.IsNullOrEmpty(flagOpts.LogFile)) opts.LogFile = flagOpts.LogFile;
if (!string.IsNullOrEmpty(flagOpts.PidFile)) opts.PidFile = flagOpts.PidFile;
if (!string.IsNullOrEmpty(flagOpts.PortsFileDir)) opts.PortsFileDir = flagOpts.PortsFileDir;
if (flagOpts.ProfPort != 0) opts.ProfPort = flagOpts.ProfPort;
if (!string.IsNullOrEmpty(flagOpts.Cluster.ListenStr)) opts.Cluster.ListenStr = flagOpts.Cluster.ListenStr;
if (flagOpts.Cluster.NoAdvertise) opts.Cluster.NoAdvertise = true;
if (flagOpts.Cluster.ConnectRetries != 0) opts.Cluster.ConnectRetries = flagOpts.Cluster.ConnectRetries;
if (!string.IsNullOrEmpty(flagOpts.Cluster.Advertise)) opts.Cluster.Advertise = flagOpts.Cluster.Advertise;
if (!string.IsNullOrEmpty(flagOpts.RoutesStr)) MergeRoutes(opts, flagOpts);
if (flagOpts.JetStream) opts.JetStream = true;
if (!string.IsNullOrEmpty(flagOpts.StoreDir)) opts.StoreDir = flagOpts.StoreDir;
return opts;
}
///
/// Parses route URLs from a comma-separated string.
/// Mirrors RoutesFromStr in opts.go.
///
public static List RoutesFromStr(string routesStr)
{
var parts = routesStr.Split(',');
if (parts.Length == 0) return [];
var urls = new List();
foreach (var r in parts)
{
var trimmed = r.Trim();
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var u))
urls.Add(u);
}
return urls;
}
///
/// Applies system-wide defaults to any unset options.
/// Mirrors setBaselineOptions in opts.go.
///
public void SetBaselineOptions()
{
if (string.IsNullOrEmpty(Host))
Host = ServerConstants.DefaultHost;
if (string.IsNullOrEmpty(HttpHost))
HttpHost = Host;
if (Port == 0)
Port = ServerConstants.DefaultPort;
else if (Port == ServerConstants.RandomPort)
Port = 0;
if (MaxConn == 0)
MaxConn = ServerConstants.DefaultMaxConnections;
if (PingInterval == TimeSpan.Zero)
PingInterval = ServerConstants.DefaultPingInterval;
if (MaxPingsOut == 0)
MaxPingsOut = ServerConstants.DefaultPingMaxOut;
if (TlsTimeout == 0)
TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
if (AuthTimeout == 0)
AuthTimeout = GetDefaultAuthTimeout(TlsConfig, TlsTimeout);
// Cluster defaults
if (Cluster.Port != 0 || !string.IsNullOrEmpty(Cluster.ListenStr))
{
if (string.IsNullOrEmpty(Cluster.Host))
Cluster.Host = ServerConstants.DefaultHost;
if (Cluster.TlsTimeout == 0)
Cluster.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
if (Cluster.AuthTimeout == 0)
Cluster.AuthTimeout = GetDefaultAuthTimeout(Cluster.TlsConfig, Cluster.TlsTimeout);
if (Cluster.PoolSize == 0)
Cluster.PoolSize = ServerConstants.DefaultRoutePoolSize;
// Add system account to pinned accounts if pool is enabled.
if (Cluster.PoolSize > 0)
{
var sysAccName = SystemAccount;
if (string.IsNullOrEmpty(sysAccName) && !NoSystemAccount)
sysAccName = ServerConstants.DefaultSystemAccount;
if (!string.IsNullOrEmpty(sysAccName) && !Cluster.PinnedAccounts.Contains(sysAccName))
Cluster.PinnedAccounts.Add(sysAccName);
}
// Default compression to "accept".
if (string.IsNullOrEmpty(Cluster.Compression.Mode))
Cluster.Compression.Mode = CompressionModes.Accept;
}
// LeafNode defaults
if (LeafNode.Port != 0)
{
if (string.IsNullOrEmpty(LeafNode.Host))
LeafNode.Host = ServerConstants.DefaultHost;
if (LeafNode.TlsTimeout == 0)
LeafNode.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
if (LeafNode.AuthTimeout == 0)
LeafNode.AuthTimeout = GetDefaultAuthTimeout(LeafNode.TlsConfig, LeafNode.TlsTimeout);
if (string.IsNullOrEmpty(LeafNode.Compression.Mode))
LeafNode.Compression.Mode = CompressionModes.S2Auto;
}
// Remote leafnode defaults
foreach (var r in LeafNode.Remotes)
{
foreach (var u in r.Urls)
{
if (u.IsDefaultPort || string.IsNullOrEmpty(u.GetComponents(UriComponents.Port, UriFormat.Unescaped)))
{
var builder = new UriBuilder(u) { Port = ServerConstants.DefaultLeafNodePort };
r.Urls[r.Urls.IndexOf(u)] = builder.Uri;
}
}
if (string.IsNullOrEmpty(r.Compression.Mode))
r.Compression.Mode = CompressionModes.S2Auto;
if (r.FirstInfoTimeout <= TimeSpan.Zero)
r.FirstInfoTimeout = ServerConstants.DefaultLeafNodeInfoWait;
}
if (LeafNode.ReconnectInterval == TimeSpan.Zero)
LeafNode.ReconnectInterval = ServerConstants.DefaultLeafNodeReconnect;
// Protocol limits
if (MaxControlLine == 0)
MaxControlLine = ServerConstants.MaxControlLineSize;
if (MaxPayload == 0)
MaxPayload = ServerConstants.MaxPayloadSize;
if (MaxPending == 0)
MaxPending = ServerConstants.MaxPendingSize;
if (WriteDeadline == TimeSpan.Zero)
WriteDeadline = ServerConstants.DefaultFlushDeadline;
if (MaxClosedClients == 0)
MaxClosedClients = ServerConstants.DefaultMaxClosedClients;
if (LameDuckDuration == TimeSpan.Zero)
LameDuckDuration = ServerConstants.DefaultLameDuckDuration;
if (LameDuckGracePeriod == TimeSpan.Zero)
LameDuckGracePeriod = ServerConstants.DefaultLameDuckGracePeriod;
// Gateway defaults
if (Gateway.Port != 0)
{
if (string.IsNullOrEmpty(Gateway.Host))
Gateway.Host = ServerConstants.DefaultHost;
if (Gateway.TlsTimeout == 0)
Gateway.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
if (Gateway.AuthTimeout == 0)
Gateway.AuthTimeout = GetDefaultAuthTimeout(Gateway.TlsConfig, Gateway.TlsTimeout);
}
// Error reporting
if (ConnectErrorReports == 0)
ConnectErrorReports = ServerConstants.DefaultConnectErrorReports;
if (ReconnectErrorReports == 0)
ReconnectErrorReports = ServerConstants.DefaultReconnectErrorReports;
// WebSocket defaults
if (Websocket.Port != 0)
{
if (string.IsNullOrEmpty(Websocket.Host))
Websocket.Host = ServerConstants.DefaultHost;
}
// MQTT defaults
if (Mqtt.Port != 0)
{
if (string.IsNullOrEmpty(Mqtt.Host))
Mqtt.Host = ServerConstants.DefaultHost;
if (Mqtt.TlsTimeout == 0)
Mqtt.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
}
// JetStream defaults
if (JetStreamMaxMemory == 0 && !MaxMemSet)
JetStreamMaxMemory = -1;
if (JetStreamMaxStore == 0 && !MaxStoreSet)
JetStreamMaxStore = -1;
if (SyncInterval == TimeSpan.Zero && !SyncSet)
SyncInterval = TimeSpan.FromMinutes(2); // defaultSyncInterval
if (JetStreamRequestQueueLimit <= 0)
JetStreamRequestQueueLimit = 4096; // JSDefaultRequestQueueLimit
}
///
/// Normalizes an HTTP base path (ensure leading slash, clean redundant separators).
/// Mirrors normalizeBasePath in opts.go.
///
public static string NormalizeBasePath(string p)
{
if (string.IsNullOrEmpty(p)) return "/";
if (p[0] != '/') p = "/" + p;
// Simple path clean: collapse repeated slashes and remove trailing slash.
while (p.Contains("//")) p = p.Replace("//", "/");
return p.Length > 1 && p.EndsWith('/') ? p[..^1] : p;
}
///
/// Computes the default auth timeout based on TLS config presence.
/// Mirrors getDefaultAuthTimeout in opts.go.
///
public static double GetDefaultAuthTimeout(object? tlsConfig, double tlsTimeout)
{
if (tlsConfig != null)
return tlsTimeout + 1.0;
return ServerConstants.AuthTimeout.TotalSeconds;
}
///
/// Returns the user's home directory.
/// Mirrors homeDir in opts.go.
///
public static string HomeDir() => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
///
/// Expands environment variables and ~/ prefix in a path.
/// Mirrors expandPath in opts.go.
///
public static string ExpandPath(string p)
{
p = Environment.ExpandEnvironmentVariables(p);
if (!p.StartsWith('~')) return p;
return Path.Combine(HomeDir(), p[1..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
}
///
/// Reads a PID from a file path if possible, otherwise returns the string as-is.
/// Mirrors maybeReadPidFile in opts.go.
///
public static string MaybeReadPidFile(string pidStr)
{
try { return File.ReadAllText(pidStr).Trim(); }
catch { return pidStr; }
}
///
/// Applies TLS overrides from command-line options.
/// Mirrors overrideTLS in opts.go.
///
public Exception? OverrideTls()
{
if (string.IsNullOrEmpty(TlsCert))
return new InvalidOperationException("TLS Server certificate must be present and valid");
if (string.IsNullOrEmpty(TlsKey))
return new InvalidOperationException("TLS Server private key must be present and valid");
// TLS config generation is deferred to GenTlsConfig (session 06+).
// For now, mark that TLS is enabled.
Tls = true;
return null;
}
///
/// Overrides cluster options from the --cluster flag.
/// Mirrors overrideCluster in opts.go.
///
public Exception? OverrideCluster()
{
if (string.IsNullOrEmpty(Cluster.ListenStr))
{
Cluster.Port = 0;
return null;
}
var listenStr = Cluster.ListenStr;
var wantsRandom = false;
if (listenStr.EndsWith(":-1"))
{
wantsRandom = true;
listenStr = listenStr[..^3] + ":0";
}
if (!Uri.TryCreate(listenStr, UriKind.Absolute, out var clusterUri))
return new InvalidOperationException($"could not parse cluster URL: {Cluster.ListenStr}");
Cluster.Host = clusterUri.Host;
Cluster.Port = wantsRandom ? -1 : clusterUri.Port;
var userInfo = clusterUri.UserInfo;
if (!string.IsNullOrEmpty(userInfo))
{
var parts = userInfo.Split(':', 2);
if (parts.Length != 2)
return new InvalidOperationException("expected cluster password to be set");
Cluster.Username = parts[0];
Cluster.Password = parts[1];
}
else
{
Cluster.Username = string.Empty;
Cluster.Password = string.Empty;
}
return null;
}
// -------------------------------------------------------------------------
// Batch 6: opts.go package-level parse/config helpers (F1)
// -------------------------------------------------------------------------
///
/// Deep copies route/gateway URL lists.
/// Mirrors deepCopyURLs in opts.go.
///
public static List? DeepCopyURLs(IReadOnlyList? urls)
{
if (urls == null)
return null;
var copied = new List(urls.Count);
foreach (var u in urls)
copied.Add(new Uri(u.ToString(), UriKind.Absolute));
return copied;
}
///
/// Loads server options from a config file.
/// Mirrors package-level ProcessConfigFile in opts.go.
///
public static ServerOptions ProcessConfigFile(string configFile) =>
ServerOptionsConfiguration.ProcessConfigFile(configFile);
///
/// Normalizes token-like values to plain CLR values.
/// Mirrors unwrapValue intent from opts.go.
///
public static object? UnwrapValue(object? value) => NormalizeConfigValue(value);
///
/// Converts a recovered panic/exception to an error list entry.
/// Mirrors convertPanicToErrorList in opts.go.
///
public static void ConvertPanicToErrorList(Exception? panic, ICollection? errors, string? context = null)
{
if (panic == null || errors == null)
return;
var message = string.IsNullOrWhiteSpace(context)
? "encountered panic while processing config"
: $"encountered panic while processing {context}";
errors.Add(new InvalidOperationException(message, panic));
}
///
/// Converts a recovered panic/exception to a single error output.
/// Mirrors convertPanicToError in opts.go.
///
public static void ConvertPanicToError(Exception? panic, ref Exception? error, string? context = null)
{
if (panic == null || error != null)
return;
var message = string.IsNullOrWhiteSpace(context)
? "encountered panic while processing config"
: $"encountered panic while processing {context}";
error = new InvalidOperationException(message, panic);
}
///
/// Applies system_account/system config values.
/// Mirrors configureSystemAccount in opts.go.
///
public static Exception? ConfigureSystemAccount(ServerOptions options, IReadOnlyDictionary config)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(config);
if (!TryGetFirst(config, ["system_account", "system"], out var value))
return null;
if (value is not string systemAccount)
return new InvalidOperationException("system account name must be a string");
options.SystemAccount = systemAccount;
return null;
}
///
/// Builds a username/nkey identity map for duplicate detection.
/// Mirrors setupUsersAndNKeysDuplicateCheckMap in opts.go.
///
public static HashSet SetupUsersAndNKeysDuplicateCheckMap(ServerOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var identities = new HashSet(StringComparer.Ordinal);
if (options.Users != null)
{
foreach (var user in options.Users)
{
if (!string.IsNullOrWhiteSpace(user.Username))
identities.Add(user.Username);
}
}
if (options.Nkeys != null)
{
foreach (var user in options.Nkeys)
{
if (!string.IsNullOrWhiteSpace(user.Nkey))
identities.Add(user.Nkey);
}
}
return identities;
}
///
/// Parses a duration from config value.
/// Mirrors parseDuration in opts.go.
///
public static TimeSpan ParseDuration(
string field,
object? value,
ICollection? errors = null,
ICollection? warnings = null)
{
if (value is string s)
{
try
{
return NatsDurationJsonConverter.Parse(s);
}
catch (Exception ex)
{
errors?.Add(new InvalidOperationException($"error parsing {field}: {ex.Message}", ex));
return TimeSpan.Zero;
}
}
if (TryConvertToLong(value, out var legacySeconds))
{
warnings?.Add(new InvalidOperationException($"{field} should be converted to a duration"));
return TimeSpan.FromSeconds(legacySeconds);
}
errors?.Add(new InvalidOperationException($"{field} should be a duration string or number of seconds"));
return TimeSpan.Zero;
}
///
/// Parses write timeout policy value.
/// Mirrors parseWriteDeadlinePolicy in opts.go.
///
public static WriteTimeoutPolicy ParseWriteDeadlinePolicy(string value, ICollection? errors = null) =>
value.ToLowerInvariant() switch
{
"default" => WriteTimeoutPolicy.Default,
"close" => WriteTimeoutPolicy.Close,
"retry" => WriteTimeoutPolicy.Retry,
_ => ParseWriteDeadlinePolicyFallback(value, errors),
};
///
/// Parses listen values (port or host:port).
/// Mirrors parseListen in opts.go.
///
public static (string Host, int Port) ParseListen(object? value)
{
if (TryConvertToLong(value, out var portOnly))
return (string.Empty, checked((int)portOnly));
if (value is not string address)
throw new InvalidOperationException($"expected port or host:port, got {value?.GetType().Name ?? "null"}");
if (!TrySplitHostPort(address, out var host, out var port))
throw new InvalidOperationException($"could not parse address string \"{address}\"");
return (host, port);
}
///
/// Parses cluster block config.
/// Mirrors parseCluster in opts.go.
///
public static Exception? ParseCluster(
object? value,
ServerOptions options,
ICollection? errors = null,
ICollection? warnings = null)
{
ArgumentNullException.ThrowIfNull(options);
if (!TryGetMap(value, out var clusterMap))
return new InvalidOperationException($"Expected map to define cluster, got {value?.GetType().Name ?? "null"}");
foreach (var (rawKey, rawValue) in clusterMap)
{
var key = rawKey.ToLowerInvariant();
var entry = NormalizeConfigValue(rawValue);
switch (key)
{
case "name":
{
var name = entry as string ?? string.Empty;
if (name.Contains(' '))
{
errors?.Add(new InvalidOperationException(ServerErrors.ErrClusterNameHasSpaces.Message));
break;
}
options.Cluster.Name = name;
break;
}
case "listen":
{
try
{
var (host, port) = ParseListen(entry);
options.Cluster.Host = host;
options.Cluster.Port = port;
}
catch (Exception ex)
{
errors?.Add(ex);
}
break;
}
case "port":
if (TryConvertToLong(entry, out var clusterPort))
options.Cluster.Port = checked((int)clusterPort);
break;
case "host":
case "net":
options.Cluster.Host = entry as string ?? string.Empty;
break;
case "authorization":
{
var auth = ParseSimpleAuthorization(entry, errors, warnings);
if (auth == null)
break;
if (auth.HasUsers)
{
errors?.Add(new InvalidOperationException("Cluster authorization does not allow multiple users"));
break;
}
if (!string.IsNullOrEmpty(auth.Token))
{
errors?.Add(new InvalidOperationException("Cluster authorization does not support tokens"));
break;
}
if (auth.HasCallout)
{
errors?.Add(new InvalidOperationException("Cluster authorization does not support callouts"));
break;
}
options.Cluster.Username = auth.Username;
options.Cluster.Password = auth.Password;
if (auth.TimeoutSeconds > 0)
options.Cluster.AuthTimeout = auth.TimeoutSeconds;
break;
}
case "routes":
if (TryGetArray(entry, out var routes))
options.Routes = ParseURLs(routes, "route", warnings, errors);
break;
case "cluster_advertise":
case "advertise":
options.Cluster.Advertise = entry as string ?? string.Empty;
break;
case "no_advertise":
if (TryConvertToBool(entry, out var noAdvertise))
{
options.Cluster.NoAdvertise = noAdvertise;
TrackExplicitVal(options.InConfig, "Cluster.NoAdvertise", noAdvertise);
}
break;
case "connect_retries":
if (TryConvertToLong(entry, out var retries))
options.Cluster.ConnectRetries = checked((int)retries);
break;
case "connect_backoff":
if (TryConvertToBool(entry, out var connectBackoff))
options.Cluster.ConnectBackoff = connectBackoff;
break;
case "compression":
{
var parseError = ParseCompression(
options.Cluster.Compression,
CompressionModes.S2Fast,
"compression",
entry);
if (parseError != null)
errors?.Add(parseError);
break;
}
case "ping_interval":
options.Cluster.PingInterval = ParseDuration("ping_interval", entry, errors, warnings);
break;
case "ping_max":
if (TryConvertToLong(entry, out var pingMax))
options.Cluster.MaxPingsOut = checked((int)pingMax);
break;
case "write_deadline":
options.Cluster.WriteDeadline = ParseDuration("write_deadline", entry, errors, warnings);
break;
case "write_timeout":
options.Cluster.WriteTimeout = ParseWriteDeadlinePolicy(entry as string ?? string.Empty, errors);
break;
default:
if (!ConfigFlags.AllowUnknownTopLevelField)
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
break;
}
}
return null;
}
///
/// Parses compression option values from bool/string/map forms.
/// Mirrors parseCompression in opts.go.
///
public static Exception? ParseCompression(
CompressionOpts compression,
string chosenModeForOn,
string fieldName,
object? value)
{
ArgumentNullException.ThrowIfNull(compression);
switch (NormalizeConfigValue(value))
{
case string mode:
compression.Mode = mode;
return null;
case bool enabled:
compression.Mode = enabled ? chosenModeForOn : CompressionModes.Off;
return null;
default:
if (!TryGetMap(value, out var map))
return new InvalidOperationException(
$"field \"{fieldName}\" should be a boolean or a structure, got {value?.GetType().Name ?? "null"}");
foreach (var (rawKey, rawValue) in map)
{
var key = rawKey.ToLowerInvariant();
var entry = NormalizeConfigValue(rawValue);
switch (key)
{
case "mode":
compression.Mode = entry as string ?? string.Empty;
break;
case "rtt_thresholds":
case "thresholds":
case "rtts":
case "rtt":
if (!TryGetArray(entry, out var thresholds))
return new InvalidOperationException("rtt_thresholds should be an array");
foreach (var threshold in thresholds)
{
if (threshold is not string thresholdValue)
return new InvalidOperationException("rtt_thresholds entries should be duration strings");
compression.RttThresholds.Add(NatsDurationJsonConverter.Parse(thresholdValue));
}
break;
default:
return new InvalidOperationException($"unknown field \"{rawKey}\"");
}
}
return null;
}
}
///
/// Parses URL arrays with duplicate detection.
/// Mirrors parseURLs in opts.go.
///
public static List ParseURLs(
IEnumerable