diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs index 7735173..2e3371f 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs @@ -407,6 +407,74 @@ public sealed class Account : INatsAccount /// private int _traceDestSampling; + /// + /// Sets account-level message trace destination subject. + /// Mirrors writes to Go acc.traceDest during config parsing. + /// + internal void SetMessageTraceDestination(string subject) + { + _mu.EnterWriteLock(); + try + { + _traceDest = subject ?? string.Empty; + } + finally + { + _mu.ExitWriteLock(); + } + } + + /// + /// Returns account-level message trace destination subject. + /// Mirrors reads of Go acc.traceDest during config parsing. + /// + internal string GetMessageTraceDestination() + { + _mu.EnterReadLock(); + try + { + return _traceDest; + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Sets account-level message trace sampling percentage. + /// Mirrors writes to Go acc.traceDestSampling during config parsing. + /// + internal void SetMessageTraceSampling(int sampling) + { + _mu.EnterWriteLock(); + try + { + _traceDestSampling = sampling; + } + finally + { + _mu.ExitWriteLock(); + } + } + + /// + /// Returns account-level message trace sampling percentage. + /// Mirrors reads of Go acc.traceDestSampling during config parsing. + /// + internal int GetMessageTraceSampling() + { + _mu.EnterReadLock(); + try + { + return _traceDestSampling; + } + finally + { + _mu.ExitReadLock(); + } + } + // ------------------------------------------------------------------------- // Factory // ------------------------------------------------------------------------- diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs index 7e93482..a514938 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs @@ -21,6 +21,7 @@ using System.Security.Cryptography.X509Certificates; using System.Net.Security; using ZB.MOM.NatsNet.Server.Auth; using ZB.MOM.NatsNet.Server.Config; +using ZB.MOM.NatsNet.Server.Internal.DataStructures; namespace ZB.MOM.NatsNet.Server; @@ -2107,6 +2108,1711 @@ public sealed partial class ServerOptions }; } + // ------------------------------------------------------------------------- + // Batch 6: opts.go package-level parse/config helpers (F3) + // ------------------------------------------------------------------------- + + /// + /// Returns true for reserved account names. + /// Mirrors isReservedAccount in opts.go. + /// + public static bool IsReservedAccount(string name) => + string.Equals(name, ServerConstants.DefaultGlobalAccount, StringComparison.Ordinal); + + /// + /// Parsed account export entry used during account-config parsing. + /// + public sealed class AccountExportConfig + { + public Account? Account { get; set; } + public string Subject { get; set; } = string.Empty; + public List AccountNames { get; set; } = []; + public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton; + public object? Latency { get; set; } + public TimeSpan ResponseThreshold { get; set; } + public uint AccountTokenPosition { get; set; } + public bool AllowTrace { get; set; } + } + + /// + /// Parsed account stream-import entry used during account-config parsing. + /// + public sealed class AccountImportStreamConfig + { + public Account? Account { get; set; } + public string AccountName { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public string Prefix { get; set; } = string.Empty; + public string To { get; set; } = string.Empty; + public bool AllowTrace { get; set; } + } + + /// + /// Parsed account service-import entry used during account-config parsing. + /// + public sealed class AccountImportServiceConfig + { + public Account? Account { get; set; } + public string AccountName { get; set; } = string.Empty; + public string Subject { get; set; } = string.Empty; + public string To { get; set; } = string.Empty; + public bool Share { get; set; } + } + + /// + /// Parsed authorization block used for top-level/config-subtree authorization parsing. + /// + public sealed class ParsedAuthorizationBlock + { + public string User { get; set; } = string.Empty; + public string Pass { get; set; } = string.Empty; + public string Token { get; set; } = string.Empty; + public double TimeoutSeconds { get; set; } + public bool ProxyRequired { get; set; } + public List Users { get; set; } = []; + public List Nkeys { get; set; } = []; + public Permissions? DefaultPermissions { get; set; } + public AuthCalloutOpts? Callout { get; set; } + } + + /// + /// Parses a weighted account mapping destination entry. + /// Mirrors parseAccountMapDest in opts.go. + /// + public static MapDest? ParseAccountMapDest(object? value, ICollection? errors = null) + { + if (!TryGetMap(value, out var map)) + { + errors?.Add(new InvalidOperationException("Expected an entry for the mapping destination")); + return null; + } + + var mdest = new MapDest(); + var sawWeight = false; + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "dest": + case "destination": + if (entry is string subject) + { + mdest.Subject = subject; + } + else + { + errors?.Add(new InvalidOperationException("mapping destination must be a string")); + return null; + } + + break; + case "weight": + { + long parsedWeight; + switch (entry) + { + case string weightString: + { + var normalizedWeight = weightString.Trim().TrimEnd('%'); + if (!long.TryParse(normalizedWeight, out parsedWeight)) + { + errors?.Add(new InvalidOperationException( + $"Invalid weight \"{weightString}\" for mapping destination")); + return null; + } + + break; + } + default: + if (!TryConvertToLong(entry, out parsedWeight)) + { + errors?.Add(new InvalidOperationException( + $"Unknown entry type for weight \"{entry?.GetType().Name ?? "null"}\"")); + return null; + } + + break; + } + + if (parsedWeight is > 100 or < 0) + { + errors?.Add(new InvalidOperationException( + $"Invalid weight {parsedWeight} for mapping destination")); + return null; + } + + mdest.Weight = (byte)parsedWeight; + sawWeight = true; + break; + } + case "cluster": + mdest.Cluster = entry as string ?? string.Empty; + break; + default: + errors?.Add(new InvalidOperationException( + $"Unknown field \"{rawKey}\" for mapping destination")); + return null; + } + } + + if (!sawWeight) + { + errors?.Add(new InvalidOperationException( + $"Missing weight for mapping destination \"{mdest.Subject}\"")); + return null; + } + + return mdest; + } + + /// + /// Parses account subject mappings. + /// Mirrors parseAccountMappings in opts.go. + /// + public static Exception? ParseAccountMappings( + object? value, + Account account, + ICollection? errors = null) + { + ArgumentNullException.ThrowIfNull(account); + if (!TryGetMap(value, out var map)) + return new InvalidOperationException($"Expected account mappings map, got {value?.GetType().Name ?? "null"}"); + + foreach (var (subject, rawDestination) in map) + { + if (!SubscriptionIndex.IsValidSubject(subject)) + { + errors?.Add(new InvalidOperationException($"Subject \"{subject}\" is not a valid subject")); + continue; + } + + var destination = NormalizeConfigValue(rawDestination); + switch (destination) + { + case string mappedSubject: + { + var mapError = account.AddMapping(subject, mappedSubject); + if (mapError != null) + { + errors?.Add(new InvalidOperationException( + $"Error adding mapping for \"{subject}\" to \"{mappedSubject}\": {mapError.Message}", + mapError)); + } + + break; + } + default: + if (TryGetArray(destination, out var destinationArray)) + { + var destinations = new List(destinationArray.Count); + foreach (var entry in destinationArray) + { + var parsedDestination = ParseAccountMapDest(entry, errors); + if (parsedDestination != null) + destinations.Add(parsedDestination); + } + + if (destinations.Count == 0) + break; + + var mapError = account.AddWeightedMappings(subject, [.. destinations]); + if (mapError != null) + { + errors?.Add(new InvalidOperationException( + $"Error adding mapping for \"{subject}\": {mapError.Message}", + mapError)); + } + + break; + } + + if (TryGetMap(destination, out _)) + { + var parsedDestination = ParseAccountMapDest(destination, errors); + if (parsedDestination == null) + break; + + var mapError = account.AddWeightedMappings(subject, parsedDestination); + if (mapError != null) + { + errors?.Add(new InvalidOperationException( + $"Error adding mapping for \"{subject}\": {mapError.Message}", + mapError)); + } + + break; + } + + errors?.Add(new InvalidOperationException( + $"Unknown type {destination?.GetType().Name ?? "null"} for mapping destination")); + break; + } + } + + return null; + } + + /// + /// Parses account-level connection/subscription/payload limits. + /// Mirrors parseAccountLimits in opts.go. + /// + public static Exception? ParseAccountLimits( + object? value, + Account account, + ICollection? errors = null) + { + ArgumentNullException.ThrowIfNull(account); + if (!TryGetMap(value, out var map)) + return new InvalidOperationException( + $"Expected account limits to be a map/struct, got {value?.GetType().Name ?? "null"}"); + + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + if (!TryConvertToLong(entry, out var numericValue)) + { + errors?.Add(new InvalidOperationException( + $"Expected numeric value parsing account limit \"{rawKey}\"")); + continue; + } + + switch (key) + { + case "max_connections": + case "max_conn": + account.MaxConnections = checked((int)numericValue); + break; + case "max_subscriptions": + case "max_subs": + account.MaxSubscriptions = checked((int)numericValue); + break; + case "max_payload": + case "max_pay": + account.MaxPayload = checked((int)numericValue); + break; + case "max_leafnodes": + case "max_leafs": + account.MaxLeafNodes = checked((int)numericValue); + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + { + errors?.Add(new InvalidOperationException( + $"Unknown field \"{rawKey}\" parsing account limits")); + } + + break; + } + } + + return null; + } + + /// + /// Parses account message trace destination/sampling configuration. + /// Mirrors parseAccountMsgTrace in opts.go. + /// + public static Exception? ParseAccountMsgTrace(object? value, string topKey, Account account) + { + ArgumentNullException.ThrowIfNull(account); + + static Exception? ProcessDestination(Account targetAccount, string key, object? rawValue) + { + if (rawValue is not string destination) + { + return new InvalidOperationException( + $"Field \"{key}\" should be a string, got {rawValue?.GetType().Name ?? "null"}"); + } + + if (!SubscriptionIndex.IsValidPublishSubject(destination)) + return new InvalidOperationException($"Trace destination \"{destination}\" is not valid"); + + targetAccount.SetMessageTraceDestination(destination); + return null; + } + + static Exception? ProcessSampling(Account targetAccount, int sampling) + { + if (sampling is <= 0 or > 100) + { + return new InvalidOperationException( + $"Trace destination sampling value {sampling} is invalid, needs to be [1..100]"); + } + + targetAccount.SetMessageTraceSampling(sampling); + return null; + } + + var normalized = NormalizeConfigValue(value); + switch (normalized) + { + case string: + return ProcessDestination(account, topKey, normalized); + default: + if (!TryGetMap(normalized, out var map)) + { + return new InvalidOperationException( + $"Expected account message trace \"{topKey}\" to be a string or map/struct, got {normalized?.GetType().Name ?? "null"}"); + } + + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "dest": + { + var destinationError = ProcessDestination(account, rawKey, entry); + if (destinationError != null) + return destinationError; + break; + } + case "sampling": + { + int parsedSampling; + switch (entry) + { + case string samplingString: + { + var normalizedSampling = samplingString.Trim().TrimEnd('%'); + if (!int.TryParse(normalizedSampling, out parsedSampling)) + { + return new InvalidOperationException( + $"Invalid trace destination sampling value \"{samplingString}\""); + } + + break; + } + default: + if (!TryConvertToLong(entry, out var longSampling)) + { + return new InvalidOperationException( + $"Trace destination sampling field \"{rawKey}\" should be an integer or a percentage, got {entry?.GetType().Name ?? "null"}"); + } + + parsedSampling = checked((int)longSampling); + break; + } + + var samplingError = ProcessSampling(account, parsedSampling); + if (samplingError != null) + return samplingError; + break; + } + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + { + return new InvalidOperationException( + $"Unknown field \"{rawKey}\" parsing account message trace map/struct \"{topKey}\""); + } + + break; + } + } + + return null; + } + } + + /// + /// Parses the top-level accounts block. + /// Mirrors parseAccounts in opts.go. + /// + public static Exception? ParseAccounts( + object? value, + ServerOptions options, + ICollection? errors = null, + ICollection? warnings = null) + { + ArgumentNullException.ThrowIfNull(options); + + var pendingImportStreams = new List(); + var pendingImportServices = new List(); + var pendingExportStreams = new List(); + var pendingExportServices = new List(); + + var normalized = NormalizeConfigValue(value); + if (TryGetArray(normalized, out var accountArray)) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (var accountEntry in accountArray) + { + var accountName = NormalizeConfigValue(accountEntry) as string ?? string.Empty; + if (string.IsNullOrWhiteSpace(accountName)) + { + errors?.Add(new InvalidOperationException("Expected account name to be a string")); + continue; + } + + if (IsReservedAccount(accountName)) + { + errors?.Add(new InvalidOperationException($"\"{accountName}\" is a reserved account")); + continue; + } + + if (!seen.Add(accountName)) + { + errors?.Add(new InvalidOperationException($"Duplicate Account Entry: {accountName}")); + continue; + } + + options.Accounts.Add(Account.NewAccount(accountName)); + } + + return null; + } + + if (!TryGetMap(normalized, out var accountsMap)) + { + return new InvalidOperationException( + $"Expected accounts to be an array or map, got {normalized?.GetType().Name ?? "null"}"); + } + + options.Users ??= []; + options.Nkeys ??= []; + var identities = SetupUsersAndNKeysDuplicateCheckMap(options); + + foreach (var (accountName, accountValueRaw) in accountsMap) + { + var accountValue = NormalizeConfigValue(accountValueRaw); + if (!TryGetMap(accountValue, out var accountMap)) + { + errors?.Add(new InvalidOperationException("Expected map entries for accounts")); + continue; + } + + if (IsReservedAccount(accountName)) + { + errors?.Add(new InvalidOperationException($"\"{accountName}\" is a reserved account")); + continue; + } + + var account = Account.NewAccount(accountName); + options.Accounts.Add(account); + + var parsedUsers = new List(); + var parsedNkeys = new List(); + + foreach (var (rawKey, rawValue) in accountMap) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "nkey": + { + var accountKey = entry as string ?? string.Empty; + if (!IsLikelyPublicNkey(accountKey, 'A')) + { + errors?.Add(new InvalidOperationException( + $"Not a valid public nkey for an account: \"{accountKey}\"")); + break; + } + + account.Nkey = accountKey; + break; + } + case "imports": + { + var (importStreams, importServices, importError) = ParseAccountImports(entry, account, errors); + if (importError != null) + { + errors?.Add(importError); + break; + } + + pendingImportStreams.AddRange(importStreams); + pendingImportServices.AddRange(importServices); + break; + } + case "exports": + { + var (exportStreams, exportServices, exportError) = ParseAccountExports(entry, account, errors); + if (exportError != null) + { + errors?.Add(exportError); + break; + } + + pendingExportStreams.AddRange(exportStreams); + pendingExportServices.AddRange(exportServices); + break; + } + case "jetstream": + { + var jsError = ParseJetStreamForAccount(entry, account, errors); + if (jsError != null) + errors?.Add(jsError); + break; + } + case "users": + { + var (accountNkeys, accountUsers, usersError) = ParseUsers(entry, errors); + if (usersError != null) + { + errors?.Add(usersError); + break; + } + + parsedUsers = accountUsers; + parsedNkeys = accountNkeys; + break; + } + case "default_permissions": + { + var permissions = ParsePermissionsValue(entry, errors); + if (permissions != null) + account.DefaultPerms = permissions; + break; + } + case "mappings": + case "maps": + { + var mappingsError = ParseAccountMappings(entry, account, errors); + if (mappingsError != null) + errors?.Add(mappingsError); + break; + } + case "limits": + { + var limitsError = ParseAccountLimits(entry, account, errors); + if (limitsError != null) + errors?.Add(limitsError); + break; + } + case "msg_trace": + case "trace_dest": + { + var traceError = ParseAccountMsgTrace(entry, key, account); + if (traceError != null) + { + errors?.Add(traceError); + break; + } + + if (!string.IsNullOrEmpty(account.GetMessageTraceDestination()) && + account.GetMessageTraceSampling() == 0) + { + account.SetMessageTraceSampling(100); + } + else if (account.GetMessageTraceSampling() > 0 && + string.IsNullOrEmpty(account.GetMessageTraceDestination())) + { + warnings?.Add(new InvalidOperationException( + "Trace destination sampling ignored since no destination was set")); + account.SetMessageTraceSampling(0); + } + + break; + } + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + if (parsedNkeys.Count > 0 || parsedUsers.Count > 0) + { + if (!string.IsNullOrEmpty(options.Username)) + errors?.Add(new InvalidOperationException("Cannot have a single user/pass and accounts")); + if (!string.IsNullOrEmpty(options.Authorization)) + errors?.Add(new InvalidOperationException("Cannot have a token and accounts")); + } + + ApplyDefaultPermissions(parsedUsers, parsedNkeys, account.DefaultPerms); + + foreach (var nkeyUser in parsedNkeys) + { + if (!identities.Add(nkeyUser.Nkey)) + { + errors?.Add(new InvalidOperationException($"Duplicate nkey \"{nkeyUser.Nkey}\" detected")); + continue; + } + + nkeyUser.Account = account; + options.Nkeys.Add(nkeyUser); + } + + foreach (var user in parsedUsers) + { + if (!identities.Add(user.Username)) + { + errors?.Add(new InvalidOperationException($"Duplicate user \"{user.Username}\" detected")); + continue; + } + + user.Account = account; + options.Users.Add(user); + } + } + + if (errors is { Count: > 0 }) + return null; + + var accountLookup = options.Accounts.ToDictionary(a => a.Name, StringComparer.Ordinal); + + foreach (var streamExport in pendingExportStreams) + { + if (streamExport.Account == null) + continue; + + streamExport.Account.Exports.Streams ??= new Dictionary(StringComparer.Ordinal); + var export = new StreamExport + { + AccountPosition = streamExport.AccountTokenPosition, + }; + + if (streamExport.AccountNames.Count > 0) + { + export.Approved = new Dictionary(StringComparer.Ordinal); + foreach (var accountName in streamExport.AccountNames) + { + if (!accountLookup.TryGetValue(accountName, out var approvedAccount)) + { + errors?.Add(new InvalidOperationException( + $"\"{accountName}\" account not defined for stream export")); + continue; + } + + export.Approved[accountName] = approvedAccount; + } + } + + streamExport.Account.Exports.Streams[streamExport.Subject] = export; + } + + foreach (var serviceExport in pendingExportServices) + { + if (serviceExport.Account == null) + continue; + + serviceExport.Account.Exports.Services ??= new Dictionary(StringComparer.Ordinal); + var export = new ServiceExportEntry + { + Account = serviceExport.Account, + ResponseType = serviceExport.ResponseType, + Latency = serviceExport.Latency as InternalServiceLatency, + ResponseThreshold = serviceExport.ResponseThreshold, + AllowTrace = serviceExport.AllowTrace, + AccountPosition = serviceExport.AccountTokenPosition, + }; + + if (serviceExport.AccountNames.Count > 0) + { + export.Approved = new Dictionary(StringComparer.Ordinal); + foreach (var accountName in serviceExport.AccountNames) + { + if (!accountLookup.TryGetValue(accountName, out var approvedAccount)) + { + errors?.Add(new InvalidOperationException( + $"\"{accountName}\" account not defined for service export")); + continue; + } + + export.Approved[accountName] = approvedAccount; + } + } + + serviceExport.Account.Exports.Services[serviceExport.Subject] = export; + } + + foreach (var streamImport in pendingImportStreams) + { + if (streamImport.Account == null) + continue; + + if (!accountLookup.TryGetValue(streamImport.AccountName, out var importedAccount)) + { + errors?.Add(new InvalidOperationException( + $"\"{streamImport.AccountName}\" account not defined for stream import")); + continue; + } + + streamImport.Account.Imports.Streams ??= []; + var targetSubject = !string.IsNullOrEmpty(streamImport.To) + ? streamImport.To + : !string.IsNullOrEmpty(streamImport.Prefix) + ? streamImport.Prefix + : streamImport.Subject; + + streamImport.Account.Imports.Streams.Add(new StreamImportEntry + { + Account = importedAccount, + From = streamImport.Subject, + To = targetSubject, + AllowTrace = streamImport.AllowTrace, + }); + } + + foreach (var serviceImport in pendingImportServices) + { + if (serviceImport.Account == null) + continue; + + if (!accountLookup.TryGetValue(serviceImport.AccountName, out var importedAccount)) + { + errors?.Add(new InvalidOperationException( + $"\"{serviceImport.AccountName}\" account not defined for service import")); + continue; + } + + var targetSubject = string.IsNullOrEmpty(serviceImport.To) + ? serviceImport.Subject + : serviceImport.To; + + serviceImport.Account.Imports.Services ??= new Dictionary>(StringComparer.Ordinal); + if (!serviceImport.Account.Imports.Services.TryGetValue(targetSubject, out var importsForSubject)) + { + importsForSubject = []; + serviceImport.Account.Imports.Services[targetSubject] = importsForSubject; + } + + importsForSubject.Add(new ServiceImportEntry + { + Account = importedAccount, + From = serviceImport.Subject, + To = targetSubject, + Share = serviceImport.Share, + }); + } + + return null; + } + + /// + /// Parses account exports list. + /// Mirrors parseAccountExports in opts.go. + /// + public static (List Streams, List Services, Exception? Error) ParseAccountExports( + object? value, + Account account, + ICollection? errors = null) + { + ArgumentNullException.ThrowIfNull(account); + if (!TryGetArray(value, out var exportArray)) + { + return ([], [], new InvalidOperationException( + $"Exports should be an array, got {value?.GetType().Name ?? "null"}")); + } + + var streamExports = new List(); + var serviceExports = new List(); + foreach (var exportValue in exportArray) + { + var (stream, service, parseError) = ParseExportStreamOrService(exportValue, errors); + if (parseError != null) + { + errors?.Add(parseError); + continue; + } + + if (stream != null) + { + stream.Account = account; + streamExports.Add(stream); + } + + if (service != null) + { + service.Account = account; + serviceExports.Add(service); + } + } + + return (streamExports, serviceExports, null); + } + + /// + /// Parses account imports list. + /// Mirrors parseAccountImports in opts.go. + /// + public static (List Streams, List Services, Exception? Error) ParseAccountImports( + object? value, + Account account, + ICollection? errors = null) + { + ArgumentNullException.ThrowIfNull(account); + if (!TryGetArray(value, out var importArray)) + { + return ([], [], new InvalidOperationException( + $"Imports should be an array, got {value?.GetType().Name ?? "null"}")); + } + + var streamImports = new List(); + var serviceImports = new List(); + var serviceSubjects = new Dictionary>(StringComparer.Ordinal); + + foreach (var importValue in importArray) + { + var (stream, service, parseError) = ParseImportStreamOrService(importValue, errors); + if (parseError != null) + { + errors?.Add(parseError); + continue; + } + + if (service != null) + { + var targetSubject = string.IsNullOrEmpty(service.To) ? service.Subject : service.To; + if (!serviceSubjects.TryGetValue(targetSubject, out var seenAccounts)) + { + seenAccounts = new HashSet(StringComparer.Ordinal); + serviceSubjects[targetSubject] = seenAccounts; + } + + if (!seenAccounts.Add(service.AccountName)) + { + errors?.Add(new InvalidOperationException( + $"Duplicate service import subject \"{targetSubject}\", previously used in import for account \"{service.AccountName}\"")); + continue; + } + + service.Account = account; + serviceImports.Add(service); + } + + if (stream != null) + { + stream.Account = account; + streamImports.Add(stream); + } + } + + return (streamImports, serviceImports, null); + } + + /// + /// Parses account descriptor maps used inside import entries. + /// Mirrors parseAccount in opts.go. + /// + public static (string AccountName, string Subject, Exception? Error) ParseAccount( + object? value, + ICollection? errors = null) + { + if (!TryGetMap(value, out var map)) + { + return (string.Empty, string.Empty, new InvalidOperationException( + $"Expected account descriptor map, got {value?.GetType().Name ?? "null"}")); + } + + var accountName = string.Empty; + var subject = string.Empty; + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "account": + accountName = entry as string ?? string.Empty; + break; + case "subject": + subject = entry as string ?? string.Empty; + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + return (accountName, subject, null); + } + + /// + /// Parses a single export entry (stream or service). + /// Mirrors parseExportStreamOrService in opts.go. + /// + public static (AccountExportConfig? Stream, AccountExportConfig? Service, Exception? Error) ParseExportStreamOrService( + object? value, + ICollection? errors = null) + { + if (!TryGetMap(value, out var map)) + { + return (null, null, new InvalidOperationException( + $"Export items should be a map with type entry, got {value?.GetType().Name ?? "null"}")); + } + + AccountExportConfig? stream = null; + AccountExportConfig? service = null; + var accountNames = new List(); + var responseType = ServiceRespType.Singleton; + var responseTypeSeen = false; + var responseThreshold = TimeSpan.Zero; + var thresholdSeen = false; + object? latency = null; + uint accountTokenPosition = 0; + var allowTraceSeen = false; + var allowTrace = false; + + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "stream": + { + if (service != null) + { + errors?.Add(new InvalidOperationException( + $"Detected stream \"{entry}\" but already saw a service")); + break; + } + + var subject = entry as string; + if (string.IsNullOrEmpty(subject)) + { + errors?.Add(new InvalidOperationException( + $"Expected stream name to be string, got {entry?.GetType().Name ?? "null"}")); + break; + } + + stream = new AccountExportConfig + { + Subject = subject, + AccountNames = [.. accountNames], + }; + break; + } + case "service": + { + if (stream != null) + { + errors?.Add(new InvalidOperationException( + $"Detected service \"{entry}\" but already saw a stream")); + break; + } + + var subject = entry as string; + if (string.IsNullOrEmpty(subject)) + { + errors?.Add(new InvalidOperationException( + $"Expected service name to be string, got {entry?.GetType().Name ?? "null"}")); + break; + } + + service = new AccountExportConfig + { + Subject = subject, + AccountNames = [.. accountNames], + ResponseType = responseType, + Latency = latency, + ResponseThreshold = responseThreshold, + AllowTrace = allowTraceSeen && allowTrace, + }; + break; + } + case "response": + case "response_type": + { + if (responseTypeSeen) + { + errors?.Add(new InvalidOperationException("Duplicate response type definition")); + break; + } + + responseTypeSeen = true; + var responseString = (entry as string ?? string.Empty).ToLowerInvariant(); + var responseTypeParsed = responseString switch + { + "single" or "singleton" => ServiceRespType.Singleton, + "stream" => ServiceRespType.Streamed, + "chunk" or "chunked" => ServiceRespType.Chunked, + _ => (ServiceRespType?)null, + }; + if (responseTypeParsed == null) + { + errors?.Add(new InvalidOperationException($"Unknown response type: \"{entry}\"")); + break; + } + + responseType = responseTypeParsed.Value; + + if (stream != null) + errors?.Add(new InvalidOperationException("Detected response directive on non-service")); + if (service != null) + service.ResponseType = responseType; + break; + } + case "threshold": + case "response_threshold": + case "response_max_time": + case "response_time": + { + if (thresholdSeen) + { + errors?.Add(new InvalidOperationException("Duplicate response threshold detected")); + break; + } + + thresholdSeen = true; + responseThreshold = ParseDuration(rawKey, entry, errors, warnings: null); + if (stream != null) + errors?.Add(new InvalidOperationException("Detected response directive on non-service")); + if (service != null) + service.ResponseThreshold = responseThreshold; + break; + } + case "accounts": + accountNames = ParseStringList(entry); + if (stream != null) + stream.AccountNames = [.. accountNames]; + if (service != null) + service.AccountNames = [.. accountNames]; + break; + case "latency": + { + var (parsedLatency, latencyError) = ParseServiceLatency(rawKey, entry); + if (latencyError != null) + { + errors?.Add(latencyError); + break; + } + + latency = parsedLatency; + if (stream != null) + { + errors?.Add(new InvalidOperationException("Detected latency directive on non-service")); + break; + } + + if (service != null) + service.Latency = latency; + break; + } + case "account_token_position": + if (TryConvertToLong(entry, out var tokenPosition)) + accountTokenPosition = checked((uint)tokenPosition); + break; + case "allow_trace": + allowTraceSeen = true; + if (TryConvertToBool(entry, out var allowTraceValue)) + allowTrace = allowTraceValue; + if (stream != null) + { + errors?.Add(new InvalidOperationException("Detected allow_trace directive on non-service")); + break; + } + + if (service != null) + service.AllowTrace = allowTrace; + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + if (stream != null) + stream.AccountTokenPosition = accountTokenPosition; + if (service != null) + service.AccountTokenPosition = accountTokenPosition; + + return (stream, service, null); + } + + /// + /// Parses service-export latency configuration. + /// Mirrors parseServiceLatency in opts.go. + /// + public static (object? Latency, Exception? Error) ParseServiceLatency(string rootField, object? value) + { + if (NormalizeConfigValue(value) is string subject) + { + return (new InternalServiceLatency + { + Subject = subject, + Sampling = ServerConstants.DefaultServiceLatencySampling, + }, null); + } + + if (!TryGetMap(value, out var latencyMap)) + { + return (null, new InvalidOperationException( + $"Expected latency entry to be a map/struct or string, got {value?.GetType().Name ?? "null"}")); + } + + var sampling = ServerConstants.DefaultServiceLatencySampling; + if (latencyMap.TryGetValue("sampling", out var samplingRaw)) + { + var samplingValue = NormalizeConfigValue(samplingRaw); + var headerMode = false; + switch (samplingValue) + { + case long longSampling: + sampling = checked((int)longSampling); + break; + case string samplingString: + { + if (samplingString.Trim().Equals("headers", StringComparison.OrdinalIgnoreCase)) + { + headerMode = true; + sampling = 0; + break; + } + + var normalizedSampling = samplingString.Trim().TrimEnd('%'); + if (!int.TryParse(normalizedSampling, out sampling)) + { + return (null, new InvalidOperationException( + $"Failed to parse latency sample \"{samplingString}\"")); + } + + break; + } + default: + return (null, new InvalidOperationException( + $"Expected latency sample to be a string or integer, got {samplingValue?.GetType().Name ?? "null"}")); + } + + if (!headerMode && (sampling < 1 || sampling > 100)) + return (null, new InvalidOperationException("sampling value should be in range [1..100]")); + } + + if (!latencyMap.TryGetValue("subject", out var subjectRaw)) + { + return (null, new InvalidOperationException( + $"Latency subject required in \"{rootField}\", but missing")); + } + + var latencySubject = NormalizeConfigValue(subjectRaw) as string; + if (string.IsNullOrWhiteSpace(latencySubject)) + { + return (null, new InvalidOperationException( + $"Expected latency subject to be a string, got {subjectRaw?.GetType().Name ?? "null"}")); + } + + return (new InternalServiceLatency + { + Sampling = sampling, + Subject = latencySubject, + }, null); + } + + /// + /// Parses a single import entry (stream or service). + /// Mirrors parseImportStreamOrService in opts.go. + /// + public static (AccountImportStreamConfig? Stream, AccountImportServiceConfig? Service, Exception? Error) ParseImportStreamOrService( + object? value, + ICollection? errors = null) + { + if (!TryGetMap(value, out var map)) + { + return (null, null, new InvalidOperationException( + $"Import items should be a map with type entry, got {value?.GetType().Name ?? "null"}")); + } + + AccountImportStreamConfig? stream = null; + AccountImportServiceConfig? service = null; + var prefix = string.Empty; + var to = string.Empty; + var share = false; + var allowTraceSeen = false; + var allowTrace = false; + + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "stream": + { + if (service != null) + { + errors?.Add(new InvalidOperationException("Detected stream but already saw a service")); + break; + } + + var (accountName, subject, parseError) = ParseAccount(entry, errors); + if (parseError != null) + { + errors?.Add(parseError); + break; + } + + if (string.IsNullOrEmpty(accountName) || string.IsNullOrEmpty(subject)) + { + errors?.Add(new InvalidOperationException("Expect an account name and a subject")); + break; + } + + stream = new AccountImportStreamConfig + { + AccountName = accountName, + Subject = subject, + Prefix = prefix, + To = to, + AllowTrace = allowTraceSeen && allowTrace, + }; + break; + } + case "service": + { + if (stream != null) + { + errors?.Add(new InvalidOperationException("Detected service but already saw a stream")); + break; + } + + if (allowTraceSeen) + { + errors?.Add(new InvalidOperationException( + "Detected allow_trace directive on a non-stream")); + break; + } + + var (accountName, subject, parseError) = ParseAccount(entry, errors); + if (parseError != null) + { + errors?.Add(parseError); + break; + } + + if (string.IsNullOrEmpty(accountName) || string.IsNullOrEmpty(subject)) + { + errors?.Add(new InvalidOperationException("Expect an account name and a subject")); + break; + } + + service = new AccountImportServiceConfig + { + AccountName = accountName, + Subject = subject, + To = string.IsNullOrEmpty(to) ? subject : to, + Share = share, + }; + break; + } + case "prefix": + prefix = entry as string ?? string.Empty; + if (stream != null) + stream.Prefix = prefix; + break; + case "to": + to = entry as string ?? string.Empty; + if (service != null) + service.To = to; + if (stream != null) + { + stream.To = to; + if (!string.IsNullOrEmpty(stream.Prefix)) + errors?.Add(new InvalidOperationException( + "Stream import cannot have both 'prefix' and 'to' properties")); + } + + break; + case "share": + if (TryConvertToBool(entry, out var shareValue)) + share = shareValue; + if (service != null) + service.Share = share; + break; + case "allow_trace": + if (service != null) + { + errors?.Add(new InvalidOperationException( + "Detected allow_trace directive on a non-stream")); + break; + } + + allowTraceSeen = true; + if (TryConvertToBool(entry, out var allowTraceValue)) + allowTrace = allowTraceValue; + if (stream != null) + stream.AllowTrace = allowTrace; + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + return (stream, service, null); + } + + /// + /// Applies default permissions to users and nkeys that do not have explicit permissions. + /// Mirrors applyDefaultPermissions in opts.go. + /// + public static void ApplyDefaultPermissions( + IReadOnlyList? users, + IReadOnlyList? nkeys, + Permissions? defaultPermissions) + { + if (defaultPermissions == null) + return; + + if (users != null) + { + foreach (var user in users) + { + user.Permissions ??= defaultPermissions; + } + } + + if (nkeys != null) + { + foreach (var user in nkeys) + { + user.Permissions ??= defaultPermissions; + } + } + } + + /// + /// Parses an authorization block. + /// Mirrors parseAuthorization in opts.go. + /// + public static (ParsedAuthorizationBlock? Authorization, Exception? Error) ParseAuthorization( + object? value, + ICollection? errors = null, + ICollection? warnings = null) + { + if (!TryGetMap(value, out var map)) + return (null, new InvalidOperationException("authorization should be a map")); + + var auth = new ParsedAuthorizationBlock(); + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "user": + case "username": + auth.User = entry as string ?? string.Empty; + break; + case "pass": + case "password": + auth.Pass = entry as string ?? string.Empty; + break; + case "token": + auth.Token = entry as string ?? string.Empty; + break; + case "timeout": + { + double timeoutSeconds; + switch (entry) + { + case long longTimeout: + timeoutSeconds = longTimeout; + break; + case double doubleTimeout: + timeoutSeconds = doubleTimeout; + break; + case string duration: + timeoutSeconds = ParseDuration("timeout", duration, errors, warnings).TotalSeconds; + break; + default: + return (null, new InvalidOperationException( + "error parsing authorization config, 'timeout' wrong type")); + } + + auth.TimeoutSeconds = timeoutSeconds; + if (timeoutSeconds > TimeSpan.FromSeconds(60).TotalSeconds) + { + warnings?.Add(new InvalidOperationException( + $"timeout of {entry} ({timeoutSeconds} seconds) is high, consider keeping it under 60 seconds")); + } + + break; + } + case "users": + { + var (nkeys, users, parseError) = ParseUsers(entry, errors); + if (parseError != null) + { + errors?.Add(parseError); + break; + } + + auth.Users = users; + auth.Nkeys = nkeys; + break; + } + case "default_permission": + case "default_permissions": + case "permissions": + auth.DefaultPermissions = ParsePermissionsValue(entry, errors); + break; + case "auth_callout": + case "auth_hook": + { + var (callout, parseError) = ParseAuthCallout(entry, errors); + if (parseError != null) + { + errors?.Add(parseError); + break; + } + + auth.Callout = callout; + break; + } + case "proxy_required": + if (TryConvertToBool(entry, out var proxyRequired)) + auth.ProxyRequired = proxyRequired; + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + ApplyDefaultPermissions(auth.Users, auth.Nkeys, auth.DefaultPermissions); + return (auth, null); + } + + /// + /// Parses users/nkeys block. + /// Mirrors parseUsers in opts.go. + /// + public static (List Nkeys, List Users, Exception? Error) ParseUsers( + object? value, + ICollection? errors = null) + { + if (!TryGetArray(value, out var usersArray)) + { + return ([], [], new InvalidOperationException( + $"Expected users field to be an array, got {value?.GetType().Name ?? "null"}")); + } + + var users = new List(); + var nkeys = new List(); + foreach (var userRaw in usersArray) + { + if (!TryGetMap(userRaw, out var userMap)) + { + errors?.Add(new InvalidOperationException( + $"Expected user entry to be a map/struct, got {userRaw?.GetType().Name ?? "null"}")); + continue; + } + + var user = new User(); + var nkey = new NkeyUser(); + Permissions? permissions = null; + foreach (var (rawKey, rawValue) in userMap) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "nkey": + nkey.Nkey = entry as string ?? string.Empty; + break; + case "user": + case "username": + user.Username = entry as string ?? string.Empty; + break; + case "pass": + case "password": + user.Password = entry as string ?? string.Empty; + break; + case "permission": + case "permissions": + case "authorization": + permissions = ParsePermissionsValue(entry, errors); + break; + case "allowed_connection_types": + case "connection_types": + case "clients": + { + var connectionTypes = ParseAllowedConnectionTypes(entry, errors); + nkey.AllowedConnectionTypes = connectionTypes; + user.AllowedConnectionTypes = connectionTypes; + break; + } + case "proxy_required": + if (TryConvertToBool(entry, out var proxyRequired)) + { + nkey.ProxyRequired = proxyRequired; + user.ProxyRequired = proxyRequired; + } + + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + if (permissions != null) + { + if (!string.IsNullOrEmpty(nkey.Nkey)) + nkey.Permissions = permissions; + else + user.Permissions = permissions; + } + + if (string.IsNullOrEmpty(nkey.Nkey) && string.IsNullOrEmpty(user.Username)) + { + return ([], [], new InvalidOperationException("User entry requires a user")); + } + + if (!string.IsNullOrEmpty(nkey.Nkey)) + { + if (!IsLikelyPublicNkey(nkey.Nkey, 'U')) + return ([], [], new InvalidOperationException("Not a valid public nkey for a user")); + + if (!string.IsNullOrEmpty(user.Username) || !string.IsNullOrEmpty(user.Password)) + { + return ([], [], new InvalidOperationException( + "Nkey users do not take usernames or passwords")); + } + + nkeys.Add(nkey); + } + else + { + users.Add(user); + } + } + + return (nkeys, users, null); + } + + /// + /// Parses allowed connection types map. + /// Mirrors parseAllowedConnectionTypes in opts.go. + /// + public static HashSet? ParseAllowedConnectionTypes( + object? value, + ICollection? errors = null) + { + if (!TryGetArray(value, out var values)) + { + errors?.Add(new InvalidOperationException( + $"Expected allowed connection types to be an array, got {value?.GetType().Name ?? "null"}")); + return null; + } + + var result = new HashSet(StringComparer.Ordinal); + foreach (var item in values) + { + var connectionType = NormalizeConfigValue(item) as string; + if (string.IsNullOrEmpty(connectionType)) + { + errors?.Add(new InvalidOperationException("allowed connection type entries must be strings")); + continue; + } + + result.Add(connectionType); + } + + var validateError = AuthHandler.ValidateAllowedConnectionTypes(result); + if (validateError != null) + errors?.Add(validateError); + + return result; + } + + /// + /// Parses authorization callout configuration. + /// Mirrors parseAuthCallout in opts.go. + /// + public static (AuthCalloutOpts? Callout, Exception? Error) ParseAuthCallout( + object? value, + ICollection? errors = null) + { + if (!TryGetMap(value, out var map)) + { + return (null, new InvalidOperationException( + $"Expected authorization callout to be a map/struct, got {value?.GetType().Name ?? "null"}")); + } + + var callout = new AuthCalloutOpts(); + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "issuer": + callout.Issuer = entry as string ?? string.Empty; + if (!IsLikelyPublicNkey(callout.Issuer, 'A')) + { + return (null, new InvalidOperationException( + $"Expected callout user to be a valid public account nkey, got \"{callout.Issuer}\"")); + } + + break; + case "account": + case "acc": + callout.Account = entry as string ?? string.Empty; + break; + case "auth_users": + case "users": + if (!TryGetArray(entry, out var authUsersArray)) + { + return (null, new InvalidOperationException( + $"Expected auth_users field to be an array, got {entry?.GetType().Name ?? "null"}")); + } + + foreach (var userValue in authUsersArray) + { + var authUser = NormalizeConfigValue(userValue) as string; + if (!string.IsNullOrEmpty(authUser)) + callout.AuthUsers.Add(authUser); + } + + break; + case "xkey": + case "key": + callout.XKey = entry as string ?? string.Empty; + if (!string.IsNullOrEmpty(callout.XKey) && !IsLikelyPublicNkey(callout.XKey, 'X')) + { + return (null, new InvalidOperationException( + $"Expected callout xkey to be a valid public xkey, got \"{callout.XKey}\"")); + } + + break; + case "allowed_accounts": + if (!TryGetArray(entry, out var allowedAccountsArray)) + { + return (null, new InvalidOperationException( + $"Expected allowed accounts field to be an array, got {entry?.GetType().Name ?? "null"}")); + } + + foreach (var accountValue in allowedAccountsArray) + { + var accountName = NormalizeConfigValue(accountValue) as string; + if (!string.IsNullOrEmpty(accountName)) + callout.AllowedAccounts.Add(accountName); + } + + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + { + errors?.Add(new InvalidOperationException( + $"Unknown field \"{rawKey}\" parsing authorization callout")); + } + + break; + } + } + + if (string.IsNullOrEmpty(callout.Account)) + callout.Account = ServerConstants.DefaultGlobalAccount; + if (string.IsNullOrEmpty(callout.Issuer)) + return (null, new InvalidOperationException("Authorization callouts require an issuer to be specified")); + if (callout.AuthUsers.Count == 0) + return (null, new InvalidOperationException("Authorization callouts require authorized users to be specified")); + + return (callout, null); + } + // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- @@ -2140,6 +3846,179 @@ public sealed partial class ServerOptions public bool ProxyRequired { get; set; } } + private static Permissions? ParsePermissionsValue(object? value, ICollection? errors) + { + if (!TryGetMap(value, out var map)) + { + errors?.Add(new InvalidOperationException( + $"Expected permissions to be a map/struct, got {value?.GetType().Name ?? "null"}")); + return null; + } + + var permissions = new Permissions(); + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "publish": + case "import": + permissions.Publish = ParseSubjectPermissionValue(entry, errors); + break; + case "subscribe": + case "export": + permissions.Subscribe = ParseSubjectPermissionValue(entry, errors); + break; + case "responses": + case "allow_responses": + permissions.Response = ParseAllowResponsesValue(entry, errors); + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + AuthHandler.ValidateResponsePermissions(permissions); + return permissions; + } + + private static SubjectPermission? ParseSubjectPermissionValue(object? value, ICollection? errors) + { + var normalized = NormalizeConfigValue(value); + if (TryGetMap(normalized, out var map)) + { + var permission = new SubjectPermission(); + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "allow": + permission.Allow = ParsePermissionSubjects(entry, errors); + break; + case "deny": + permission.Deny = ParsePermissionSubjects(entry, errors); + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + return permission; + } + + return new SubjectPermission + { + Allow = ParsePermissionSubjects(normalized, errors), + }; + } + + private static List ParsePermissionSubjects(object? value, ICollection? errors) + { + if (TryGetArray(value, out var array)) + { + var subjects = new List(array.Count); + foreach (var entry in array) + { + var subject = NormalizeConfigValue(entry) as string; + if (string.IsNullOrWhiteSpace(subject)) + { + errors?.Add(new InvalidOperationException("permission subjects must be non-empty strings")); + continue; + } + + if (!SubscriptionIndex.IsValidSubject(subject)) + { + errors?.Add(new InvalidOperationException($"invalid subject \"{subject}\" in permissions")); + continue; + } + + subjects.Add(subject); + } + + return subjects; + } + + if (NormalizeConfigValue(value) is string singleSubject) + { + if (!SubscriptionIndex.IsValidSubject(singleSubject)) + { + errors?.Add(new InvalidOperationException($"invalid subject \"{singleSubject}\" in permissions")); + return []; + } + + return [singleSubject]; + } + + errors?.Add(new InvalidOperationException( + $"Expected permission subject array/string, got {value?.GetType().Name ?? "null"}")); + return []; + } + + private static ResponsePermission? ParseAllowResponsesValue(object? value, ICollection? errors) + { + var normalized = NormalizeConfigValue(value); + if (TryConvertToBool(normalized, out var enabled)) + { + return enabled + ? new ResponsePermission + { + MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs, + Expires = ServerConstants.DefaultAllowResponseExpiration, + } + : null; + } + + if (!TryGetMap(normalized, out var map)) + { + errors?.Add(new InvalidOperationException( + $"Expected allow_responses to be a boolean or map, got {normalized?.GetType().Name ?? "null"}")); + return null; + } + + var response = new ResponsePermission + { + MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs, + Expires = ServerConstants.DefaultAllowResponseExpiration, + }; + + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "max": + case "max_msgs": + if (TryConvertToLong(entry, out var maxMessages)) + response.MaxMsgs = checked((int)maxMessages); + else + errors?.Add(new InvalidOperationException("allow_responses.max should be an integer")); + break; + case "expires": + response.Expires = ParseDuration("allow_responses.expires", entry, errors, warnings: null); + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + return response; + } + + private static bool IsLikelyPublicNkey(string value, char prefix) => + !string.IsNullOrWhiteSpace(value) && + value.Length >= 10 && + value[0] == prefix; + private static WriteTimeoutPolicy ParseWriteDeadlinePolicyFallback(string value, ICollection? errors) { errors?.Add(new InvalidOperationException( diff --git a/porting.db b/porting.db index 26d0c0e..d326e7a 100644 Binary files a/porting.db and b/porting.db differ diff --git a/reports/current.md b/reports/current.md index 9b5c14c..fadedc9 100644 --- a/reports/current.md +++ b/reports/current.md @@ -1,6 +1,6 @@ # NATS .NET Porting Status Report -Generated: 2026-02-28 14:37:42 UTC +Generated: 2026-02-28 14:47:33 UTC ## Modules (12 total) @@ -12,10 +12,10 @@ Generated: 2026-02-28 14:37:42 UTC | Status | Count | |--------|-------| -| deferred | 2094 | +| deferred | 2077 | | n_a | 24 | | stub | 1 | -| verified | 1554 | +| verified | 1571 | ## Unit Tests (3257 total) @@ -34,4 +34,4 @@ Generated: 2026-02-28 14:37:42 UTC ## Overall Progress -**2799/6942 items complete (40.3%)** +**2816/6942 items complete (40.6%)**