diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs index b6a9681..a1a87da 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs @@ -60,8 +60,13 @@ public enum OcspMode : byte /// public class PinnedCertSet : HashSet { - public PinnedCertSet() : base(StringComparer.OrdinalIgnoreCase) { } - public PinnedCertSet(IEnumerable collection) : base(collection, StringComparer.OrdinalIgnoreCase) { } + public PinnedCertSet() : base(StringComparer.OrdinalIgnoreCase) + { + } + + public PinnedCertSet(IEnumerable collection) : base(collection, StringComparer.OrdinalIgnoreCase) + { + } } /// @@ -105,7 +110,8 @@ public class TlsConfigOpts public double Timeout { get; set; } public long RateLimit { get; set; } public bool AllowInsecureCiphers { get; set; } - public List CurvePreferences { get; set; } = []; + public List Ciphers { get; set; } = []; + public List CurvePreferences { get; set; } = []; public PinnedCertSet? PinnedCerts { get; set; } public string CertMatch { get; set; } = string.Empty; public bool CertMatchSkipInvalid { get; set; } @@ -431,7 +437,7 @@ public class ProxyConfig /// Parsed authorization section from config file. /// Mirrors the unexported authorization struct in opts.go. /// -internal class AuthorizationConfig +public class AuthorizationConfig { public string User { get; set; } = string.Empty; public string Pass { get; set; } = string.Empty; diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs index a514938..f326444 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs @@ -15,6 +15,7 @@ using System.Runtime.InteropServices; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; @@ -3813,6 +3814,1579 @@ public sealed partial class ServerOptions return (callout, null); } + // ------------------------------------------------------------------------- + // Batch 6: opts.go package-level parse/config helpers (F4) + // ------------------------------------------------------------------------- + + /// + /// Parses user permission blocks. + /// Mirrors parseUserPermissions in opts.go. + /// + public static (Permissions? Permissions, Exception? Error) ParseUserPermissions( + object? value, + ICollection? errors = null) + { + if (!TryGetMap(value, out var map)) + { + return (null, new InvalidOperationException( + $"Expected permissions to be a map/struct, got {value?.GetType().Name ?? "null"}")); + } + + var permissions = new Permissions(); + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "pub": + case "publish": + case "import": + { + var (subjectPermissions, parseError) = ParseVariablePermissions(entry, errors); + if (parseError != null) + { + errors?.Add(parseError); + break; + } + + permissions.Publish = subjectPermissions; + break; + } + case "sub": + case "subscribe": + case "export": + { + var (subjectPermissions, parseError) = ParseVariablePermissions(entry, errors); + if (parseError != null) + { + errors?.Add(parseError); + break; + } + + permissions.Subscribe = subjectPermissions; + break; + } + case "publish_allow_responses": + case "allow_responses": + if (TryConvertToBool(entry, out var responsesEnabled)) + { + if (responsesEnabled) + { + permissions.Response = new ResponsePermission + { + MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs, + Expires = ServerConstants.DefaultAllowResponseExpiration, + }; + } + } + else + { + permissions.Response = ParseAllowResponses(entry, errors); + } + + if (permissions.Response != null) + { + permissions.Publish ??= new SubjectPermission(); + permissions.Publish.Allow ??= []; + } + + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + { + errors?.Add(new InvalidOperationException( + $"Unknown field \"{rawKey}\" parsing permissions")); + } + + break; + } + } + + AuthHandler.ValidateResponsePermissions(permissions); + return (permissions, null); + } + + /// + /// Parses variable-style publish/subscribe permission values. + /// Mirrors parseVariablePermissions in opts.go. + /// + public static (SubjectPermission? Permissions, Exception? Error) ParseVariablePermissions( + object? value, + ICollection? errors = null) + { + return TryGetMap(value, out _) + ? ParseSubjectPermission(value, errors) + : ParseOldPermissionStyle(value, errors); + } + + /// + /// Parses single or array subject values used in permissions. + /// Mirrors parsePermSubjects in opts.go. + /// + public static (List? Subjects, Exception? Error) ParsePermSubjects( + object? value, + ICollection? errors = null) + { + var normalized = NormalizeConfigValue(value); + var subjects = new List(); + + switch (normalized) + { + case string single: + subjects.Add(single); + break; + case IEnumerable array: + foreach (var entry in array) + { + var subject = NormalizeConfigValue(entry) as string; + if (subject == null) + { + return (null, new InvalidOperationException( + "Subject in permissions array cannot be cast to string")); + } + + subjects.Add(subject); + } + + break; + default: + return (null, new InvalidOperationException( + $"Expected subject permissions to be a subject, or array of subjects, got {normalized?.GetType().Name ?? "null"}")); + } + + var validateError = CheckPermSubjectArray(subjects); + if (validateError != null) + return (null, validateError); + + return (subjects, null); + } + + /// + /// Parses response permissions. + /// Mirrors parseAllowResponses in opts.go. + /// + public static ResponsePermission? ParseAllowResponses( + object? value, + ICollection? errors = null) + { + 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( + "error parsing response permissions, expected a boolean or a map")); + return null; + } + + var responsePermission = 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": + case "max_messages": + case "max_responses": + if (!TryConvertToLong(entry, out var maxMessages)) + { + errors?.Add(new InvalidOperationException("error parsing max responses")); + break; + } + + if (maxMessages != 0) + responsePermission.MaxMsgs = checked((int)maxMessages); + break; + case "expires": + case "expiration": + case "ttl": + { + var ttl = ParseDuration("expires", entry, errors, warnings: null); + if (ttl != TimeSpan.Zero) + responsePermission.Expires = ttl; + break; + } + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + { + errors?.Add(new InvalidOperationException( + $"Unknown field \"{rawKey}\" parsing permissions")); + } + + break; + } + } + + return responsePermission; + } + + /// + /// Parses old-style allow-only permission syntax. + /// Mirrors parseOldPermissionStyle in opts.go. + /// + public static (SubjectPermission? Permissions, Exception? Error) ParseOldPermissionStyle( + object? value, + ICollection? errors = null) + { + var (subjects, parseError) = ParsePermSubjects(value, errors); + if (parseError != null) + return (null, parseError); + + return (new SubjectPermission { Allow = subjects }, null); + } + + /// + /// Parses new-style allow/deny subject permissions. + /// Mirrors parseSubjectPermission in opts.go. + /// + public static (SubjectPermission? Permissions, Exception? Error) ParseSubjectPermission( + object? value, + ICollection? errors = null) + { + if (!TryGetMap(value, out var map)) + { + return (null, new InvalidOperationException( + $"Expected subject permission map, got {value?.GetType().Name ?? "null"}")); + } + + if (map.Count == 0) + return (null, null); + + var permission = new SubjectPermission(); + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "allow": + { + var (subjects, parseError) = ParsePermSubjects(entry, errors); + if (parseError != null) + { + errors?.Add(parseError); + break; + } + + permission.Allow = subjects; + break; + } + case "deny": + { + var (subjects, parseError) = ParsePermSubjects(entry, errors); + if (parseError != null) + { + errors?.Add(parseError); + break; + } + + permission.Deny = subjects; + break; + } + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + { + errors?.Add(new InvalidOperationException( + $"Unknown field name \"{rawKey}\" parsing subject permissions, only 'allow' or 'deny' are permitted")); + } + + break; + } + } + + return (permission, null); + } + + /// + /// Validates permission subjects. + /// Mirrors checkPermSubjectArray in opts.go. + /// + public static Exception? CheckPermSubjectArray(IReadOnlyList subjects) + { + foreach (var subject in subjects) + { + if (SubscriptionIndex.IsValidSubject(subject)) + continue; + + var parts = subject.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 2) + return new InvalidOperationException($"subject \"{subject}\" is not a valid subject"); + if (!SubscriptionIndex.IsValidSubject(parts[0])) + return new InvalidOperationException($"subject \"{parts[0]}\" is not a valid subject"); + } + + return null; + } + + /// + /// Prints TLS help text. + /// Mirrors PrintTLSHelpAndDie in opts.go. + /// + public static void PrintTLSHelpAndDie() + { + Console.WriteLine("TLS configuration help"); + Console.WriteLine("Available cipher suites include:"); + foreach (var cipher in CipherSuites.CipherMap.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + Console.WriteLine($" {cipher}"); + + Console.WriteLine(); + Console.WriteLine("Available curve preferences include:"); + foreach (var curve in CipherSuites.CurvePreferenceMap.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + Console.WriteLine($" {curve}"); + } + + /// + /// Parses a configured cipher-suite name. + /// Mirrors parseCipher in opts.go. + /// + public static (TlsCipherSuite? Cipher, Exception? Error) ParseCipher(string cipherName) + { + if (!CipherSuites.CipherMap.TryGetValue(cipherName, out var cipher)) + return (null, new InvalidOperationException($"unrecognized cipher {cipherName}")); + + return (cipher, null); + } + + /// + /// Parses a configured curve-preference name. + /// Mirrors parseCurvePreferences in opts.go. + /// + public static (SslApplicationProtocol? Curve, Exception? Error) ParseCurvePreferences(string curveName) + { + if (!CipherSuites.CurvePreferenceMap.TryGetValue(curveName, out var curve)) + return (null, new InvalidOperationException($"unrecognized curve preference {curveName}")); + + return (curve, null); + } + + /// + /// Parses minimum TLS version config value. + /// Mirrors parseTLSVersion in opts.go. + /// + public static (SslProtocols Version, Exception? Error) ParseTLSVersion(object? value) + { + if (NormalizeConfigValue(value) is not string versionText) + return (SslProtocols.None, new InvalidOperationException($"'min_version' wrong type: {value}")); + + SslProtocols minVersion; + try + { + minVersion = TlsVersionJsonConverter.Parse(versionText); + } + catch (Exception ex) + { + return (SslProtocols.None, ex); + } + + if (minVersion != SslProtocols.Tls12 && minVersion != SslProtocols.Tls13) + { + return (SslProtocols.None, new InvalidOperationException( + $"unsupported TLS version: {versionText}")); + } + + return (minVersion, null); + } + + /// + /// Parses TLS config options from map values. + /// Mirrors parseTLS in opts.go. + /// + public static (TlsConfigOpts? Options, Exception? Error) ParseTLS(object? value, bool isClientCtx) + { + if (!TryGetMap(value, out var map)) + return (null, new InvalidOperationException("TLS options should be a map")); + + var tlsOptions = new TlsConfigOpts(); + var insecureConfigured = new List(); + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "cert_file": + tlsOptions.CertFile = entry as string ?? string.Empty; + break; + case "key_file": + tlsOptions.KeyFile = entry as string ?? string.Empty; + break; + case "ca_file": + tlsOptions.CaFile = entry as string ?? string.Empty; + break; + case "insecure": + if (!TryConvertToBool(entry, out var insecure)) + return (null, new InvalidOperationException("error parsing tls config, expected 'insecure' to be a boolean")); + tlsOptions.Insecure = insecure; + break; + case "verify": + if (!TryConvertToBool(entry, out var verify)) + return (null, new InvalidOperationException("error parsing tls config, expected 'verify' to be a boolean")); + tlsOptions.Verify = verify; + break; + case "verify_and_map": + if (!TryConvertToBool(entry, out var verifyAndMap)) + return (null, new InvalidOperationException("error parsing tls config, expected 'verify_and_map' to be a boolean")); + if (verifyAndMap) + tlsOptions.Verify = true; + tlsOptions.Map = verifyAndMap; + break; + case "verify_cert_and_check_known_urls": + if (!TryConvertToBool(entry, out var verifyKnownUrls)) + { + return (null, new InvalidOperationException( + "error parsing tls config, expected 'verify_cert_and_check_known_urls' to be a boolean")); + } + + if (verifyKnownUrls && isClientCtx) + { + return (null, new InvalidOperationException( + "verify_cert_and_check_known_urls not supported in this context")); + } + + if (verifyKnownUrls) + tlsOptions.Verify = true; + tlsOptions.TlsCheckKnownUrls = verifyKnownUrls; + break; + case "allow_insecure_cipher_suites": + if (!TryConvertToBool(entry, out var allowInsecureCiphers)) + { + return (null, new InvalidOperationException( + "error parsing tls config, expected 'allow_insecure_cipher_suites' to be a boolean")); + } + + tlsOptions.AllowInsecureCiphers = allowInsecureCiphers; + break; + case "cipher_suites": + if (!TryGetArray(entry, out var cipherArray) || cipherArray.Count == 0) + { + return (null, new InvalidOperationException( + "error parsing tls config, 'cipher_suites' cannot be empty")); + } + + tlsOptions.Ciphers.Clear(); + foreach (var cipherEntry in cipherArray) + { + if (NormalizeConfigValue(cipherEntry) is not string cipherName) + return (null, new InvalidOperationException("cipher suite name should be a string")); + + var (cipher, parseError) = ParseCipher(cipherName); + if (parseError != null || cipher == null) + return (null, parseError); + + tlsOptions.Ciphers.Add(cipher.Value); + if (IsLikelyInsecureCipherSuite(cipher.Value)) + insecureConfigured.Add(cipherName); + } + + break; + case "curve_preferences": + if (!TryGetArray(entry, out var curveArray) || curveArray.Count == 0) + { + return (null, new InvalidOperationException( + "error parsing tls config, 'curve_preferences' cannot be empty")); + } + + tlsOptions.CurvePreferences.Clear(); + foreach (var curveEntry in curveArray) + { + if (NormalizeConfigValue(curveEntry) is not string curveName) + return (null, new InvalidOperationException("curve preference should be a string")); + + var (curve, parseError) = ParseCurvePreferences(curveName); + if (parseError != null || curve == null) + return (null, parseError); + + tlsOptions.CurvePreferences.Add(curve.Value); + } + + break; + case "timeout": + switch (entry) + { + case long timeoutLong: + tlsOptions.Timeout = timeoutLong; + break; + case double timeoutDouble: + tlsOptions.Timeout = timeoutDouble; + break; + case string timeoutString: + tlsOptions.Timeout = ParseDuration("tls timeout", timeoutString).TotalSeconds; + break; + default: + return (null, new InvalidOperationException("error parsing tls config, 'timeout' wrong type")); + } + + break; + case "connection_rate_limit": + if (!TryConvertToLong(entry, out var rateLimit)) + return (null, new InvalidOperationException("error parsing tls config, 'connection_rate_limit' wrong type")); + tlsOptions.RateLimit = rateLimit; + break; + case "pinned_certs": + if (!TryGetArray(entry, out var pinArray)) + { + return (null, new InvalidOperationException( + "error parsing tls config, expected 'pinned_certs' to be a list")); + } + + if (pinArray.Count > 0) + { + var pinned = new PinnedCertSet(); + var pinRegex = new Regex("^[A-Fa-f0-9]{64}$", RegexOptions.Compiled); + foreach (var pinEntry in pinArray) + { + var pin = (NormalizeConfigValue(pinEntry) as string ?? string.Empty).ToLowerInvariant(); + if (!pinRegex.IsMatch(pin)) + { + return (null, new InvalidOperationException( + $"error parsing tls config, 'pinned_certs' key {pin} does not look like hex-encoded sha256")); + } + + pinned.Add(pin); + } + + tlsOptions.PinnedCerts = pinned; + } + + break; + case "handshake_first": + case "first": + case "immediate": + switch (entry) + { + case bool handshakeFirst: + tlsOptions.HandshakeFirst = handshakeFirst; + break; + case string handshakeValue: + switch (handshakeValue.Trim().ToLowerInvariant()) + { + case "true": + case "on": + tlsOptions.HandshakeFirst = true; + break; + case "false": + case "off": + tlsOptions.HandshakeFirst = false; + break; + case "auto": + case "auto_fallback": + tlsOptions.HandshakeFirst = true; + tlsOptions.FallbackDelay = ServerConstants.DefaultTlsHandshakeFirstFallbackDelay; + break; + default: + { + var delay = ParseDuration("handshake_first", handshakeValue); + if (delay == TimeSpan.Zero) + { + return (null, new InvalidOperationException( + $"field \"{rawKey}\" value \"{handshakeValue}\" is invalid")); + } + + tlsOptions.HandshakeFirst = true; + tlsOptions.FallbackDelay = delay; + break; + } + } + + break; + default: + return (null, new InvalidOperationException( + $"field \"{rawKey}\" should be a boolean or a string, got {entry?.GetType().Name ?? "null"}")); + } + + break; + case "certs": + case "certificates": + if (!TryGetArray(entry, out var certsArray)) + { + return (null, new InvalidOperationException( + $"error parsing certificates config: unsupported type {entry?.GetType().Name ?? "null"}")); + } + + tlsOptions.Certificates.Clear(); + foreach (var certEntry in certsArray) + { + if (!TryGetMap(certEntry, out var certMap)) + { + return (null, new InvalidOperationException( + $"error parsing certificates config: unsupported type {certEntry?.GetType().Name ?? "null"}")); + } + + var pair = new TlsCertPairOpt(); + foreach (var (certKey, certValueRaw) in certMap) + { + var certValue = NormalizeConfigValue(certValueRaw) as string; + if (string.IsNullOrEmpty(certValue)) + { + return (null, new InvalidOperationException( + $"error parsing certificates config: unsupported type {certValueRaw?.GetType().Name ?? "null"}")); + } + + switch (certKey) + { + case "cert_file": + pair.CertFile = certValue; + break; + case "key_file": + pair.KeyFile = certValue; + break; + default: + return (null, new InvalidOperationException( + $"error parsing tls certs config, unknown field \"{certKey}\"")); + } + } + + if (string.IsNullOrEmpty(pair.CertFile) || string.IsNullOrEmpty(pair.KeyFile)) + { + return (null, new InvalidOperationException( + "error parsing certificates config: both 'cert_file' and 'key_file' options are required")); + } + + tlsOptions.Certificates.Add(pair); + } + + break; + case "min_version": + { + var (minVersion, parseError) = ParseTLSVersion(entry); + if (parseError != null) + return (null, new InvalidOperationException($"error parsing tls config: {parseError.Message}", parseError)); + tlsOptions.MinVersion = minVersion; + break; + } + case "cert_match": + tlsOptions.CertMatch = entry as string ?? string.Empty; + break; + case "cert_match_skip_invalid": + if (!TryConvertToBool(entry, out var certMatchSkipInvalid)) + { + return (null, new InvalidOperationException( + "error parsing tls config, expected 'cert_match_skip_invalid' to be a boolean")); + } + + tlsOptions.CertMatchSkipInvalid = certMatchSkipInvalid; + break; + case "ca_certs_match": + { + var (caCertsMatch, parseError) = ParseStringArray("ca_certs_match", entry, errors: null); + if (parseError != null || caCertsMatch == null) + return (null, parseError); + tlsOptions.CaCertsMatch = caCertsMatch; + break; + } + default: + return (null, new InvalidOperationException( + $"error parsing tls config, unknown field \"{rawKey}\"")); + } + } + + if (tlsOptions.Certificates.Count > 0 && !string.IsNullOrEmpty(tlsOptions.CertFile)) + { + return (null, new InvalidOperationException( + "error parsing tls config, cannot combine 'cert_file' option with 'certs' option")); + } + + if (tlsOptions.Ciphers.Count == 0) + tlsOptions.Ciphers.AddRange(CipherSuites.DefaultCipherSuites()); + + if (tlsOptions.CurvePreferences.Count == 0) + { + foreach (var curveName in CipherSuites.DefaultCurvePreferences()) + { + if (CipherSuites.CurvePreferenceMap.TryGetValue(curveName, out var curve)) + tlsOptions.CurvePreferences.Add(curve); + } + } + + if (!tlsOptions.AllowInsecureCiphers && insecureConfigured.Count > 0) + { + return (null, new InvalidOperationException( + $"insecure cipher suites configured without 'allow_insecure_cipher_suites' option set: {string.Join(", ", insecureConfigured)}")); + } + + return (tlsOptions, null); + } + + /// + /// Parses simple auth objects (user/pass/token/timeout). + /// Mirrors parseSimpleAuth in opts.go. + /// + public static AuthorizationConfig ParseSimpleAuth( + object? value, + ICollection? errors = null) + { + var auth = new AuthorizationConfig(); + if (!TryGetMap(value, out var map)) + { + errors?.Add(new InvalidOperationException("authorization should be a map")); + return auth; + } + + 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": + if (TryConvertToDouble(entry, out var timeout)) + auth.Timeout = timeout; + else + errors?.Add(new InvalidOperationException("error parsing authorization timeout")); + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + return auth; + } + + /// + /// Parses string or array-of-string fields. + /// Mirrors parseStringArray in opts.go. + /// + public static (List? Values, Exception? Error) ParseStringArray( + string fieldName, + object? value, + ICollection? errors = null) + { + switch (NormalizeConfigValue(value)) + { + case string text: + return ([text], null); + case IEnumerable array: + { + var values = new List(); + foreach (var entry in array) + { + if (NormalizeConfigValue(entry) is string item) + { + values.Add(item); + continue; + } + + var parseError = new InvalidOperationException( + $"error parsing {fieldName}: unsupported type in array {entry?.GetType().Name ?? "null"}"); + errors?.Add(parseError); + } + + return (values, null); + } + default: + { + var parseError = new InvalidOperationException( + $"error parsing {fieldName}: unsupported type {value?.GetType().Name ?? "null"}"); + errors?.Add(parseError); + return (null, parseError); + } + } + } + + /// + /// Parses websocket configuration block. + /// Mirrors parseWebsocket in opts.go. + /// + public static Exception? ParseWebsocket( + object? value, + ServerOptions options, + ICollection? errors = null, + ICollection? warnings = null) + { + ArgumentNullException.ThrowIfNull(options); + + if (!TryGetMap(value, out var map)) + return new InvalidOperationException($"Expected websocket to be a map, got {value?.GetType().Name ?? "null"}"); + + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "listen": + try + { + var (host, port) = ParseListen(entry); + options.Websocket.Host = host; + options.Websocket.Port = port; + } + catch (Exception ex) + { + errors?.Add(ex); + } + + break; + case "port": + if (TryConvertToLong(entry, out var websocketPort)) + options.Websocket.Port = checked((int)websocketPort); + break; + case "host": + case "net": + options.Websocket.Host = entry as string ?? string.Empty; + break; + case "advertise": + options.Websocket.Advertise = entry as string ?? string.Empty; + break; + case "no_tls": + if (TryConvertToBool(entry, out var noTls)) + options.Websocket.NoTls = noTls; + break; + case "tls": + { + var (tlsOptions, parseError) = ParseTLS(entry, isClientCtx: true); + if (parseError != null || tlsOptions == null) + { + errors?.Add(parseError ?? new InvalidOperationException("unable to parse websocket tls options")); + break; + } + + var (tlsConfig, genError) = GenTLSConfig(tlsOptions); + if (genError != null) + { + errors?.Add(genError); + break; + } + + options.Websocket.TlsConfig = tlsConfig; + options.Websocket.TlsMap = tlsOptions.Map; + options.Websocket.TlsPinnedCerts = tlsOptions.PinnedCerts; + options.Websocket.TlsConfigOpts = tlsOptions; + break; + } + case "same_origin": + if (TryConvertToBool(entry, out var sameOrigin)) + options.Websocket.SameOrigin = sameOrigin; + break; + case "allowed_origins": + case "allowed_origin": + case "allow_origins": + case "allow_origin": + case "origins": + case "origin": + { + var (origins, parseError) = ParseStringArray("allowed origins", entry, errors); + if (parseError == null && origins != null) + options.Websocket.AllowedOrigins = origins; + break; + } + case "handshake_timeout": + options.Websocket.HandshakeTimeout = ParseDuration("handshake timeout", entry, errors, warnings); + break; + case "compress": + case "compression": + if (TryConvertToBool(entry, out var websocketCompression)) + options.Websocket.Compression = websocketCompression; + break; + case "authorization": + case "authentication": + { + var auth = ParseSimpleAuth(entry, errors); + options.Websocket.Username = auth.User; + options.Websocket.Password = auth.Pass; + options.Websocket.Token = auth.Token; + options.Websocket.AuthTimeout = auth.Timeout; + break; + } + case "jwt_cookie": + options.Websocket.JwtCookie = entry as string ?? string.Empty; + break; + case "user_cookie": + options.Websocket.UsernameCookie = entry as string ?? string.Empty; + break; + case "pass_cookie": + options.Websocket.PasswordCookie = entry as string ?? string.Empty; + break; + case "token_cookie": + options.Websocket.TokenCookie = entry as string ?? string.Empty; + break; + case "no_auth_user": + options.Websocket.NoAuthUser = entry as string ?? string.Empty; + break; + case "headers": + if (!TryGetMap(entry, out var headerMap)) + { + errors?.Add(new InvalidOperationException( + $"error parsing headers: unsupported type {entry?.GetType().Name ?? "null"}")); + break; + } + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (headerName, headerValueRaw) in headerMap) + { + if (NormalizeConfigValue(headerValueRaw) is string headerValue) + { + headers[headerName] = headerValue; + } + else + { + errors?.Add(new InvalidOperationException( + $"error parsing header key {headerName}: unsupported type {headerValueRaw?.GetType().Name ?? "null"}")); + } + } + + options.Websocket.Headers = headers; + break; + case "ping_interval": + options.Websocket.PingInterval = ParseDuration("ping_interval", entry, errors, warnings); + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + return null; + } + + /// + /// Parses MQTT configuration block. + /// Mirrors parseMQTT in opts.go. + /// + public static Exception? ParseMQTT( + object? value, + ServerOptions options, + ICollection? errors = null, + ICollection? warnings = null) + { + ArgumentNullException.ThrowIfNull(options); + + if (!TryGetMap(value, out var map)) + return new InvalidOperationException($"Expected mqtt to be a map, got {value?.GetType().Name ?? "null"}"); + + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "listen": + try + { + var (host, port) = ParseListen(entry); + options.Mqtt.Host = host; + options.Mqtt.Port = port; + } + catch (Exception ex) + { + errors?.Add(ex); + } + + break; + case "port": + if (TryConvertToLong(entry, out var mqttPort)) + options.Mqtt.Port = checked((int)mqttPort); + break; + case "host": + case "net": + options.Mqtt.Host = entry as string ?? string.Empty; + break; + case "tls": + { + var (tlsOptions, parseError) = ParseTLS(entry, isClientCtx: true); + if (parseError != null || tlsOptions == null) + { + errors?.Add(parseError ?? new InvalidOperationException("unable to parse mqtt tls options")); + break; + } + + var (tlsConfig, genError) = GenTLSConfig(tlsOptions); + if (genError != null) + { + errors?.Add(genError); + break; + } + + options.Mqtt.TlsConfig = tlsConfig; + options.Mqtt.TlsTimeout = tlsOptions.Timeout; + options.Mqtt.TlsMap = tlsOptions.Map; + options.Mqtt.TlsPinnedCerts = tlsOptions.PinnedCerts; + options.Mqtt.TlsConfigOpts = tlsOptions; + break; + } + case "authorization": + case "authentication": + { + var auth = ParseSimpleAuth(entry, errors); + options.Mqtt.Username = auth.User; + options.Mqtt.Password = auth.Pass; + options.Mqtt.Token = auth.Token; + options.Mqtt.AuthTimeout = auth.Timeout; + break; + } + case "no_auth_user": + options.Mqtt.NoAuthUser = entry as string ?? string.Empty; + break; + case "ack_wait": + case "ackwait": + options.Mqtt.AckWait = ParseDuration("ack_wait", entry, errors, warnings); + break; + case "js_api_timeout": + case "api_timeout": + options.Mqtt.JsApiTimeout = ParseDuration("js_api_timeout", entry, errors, warnings); + break; + case "max_ack_pending": + case "max_pending": + case "max_inflight": + if (!TryConvertToLong(entry, out var maxAckPending)) + { + errors?.Add(new InvalidOperationException("invalid max_ack_pending value")); + break; + } + + if (maxAckPending is < 0 or > ushort.MaxValue) + { + errors?.Add(new InvalidOperationException( + $"invalid value {maxAckPending}, should be in [0..{ushort.MaxValue}] range")); + break; + } + + options.Mqtt.MaxAckPending = (ushort)maxAckPending; + break; + case "js_domain": + options.Mqtt.JsDomain = entry as string ?? string.Empty; + break; + case "stream_replicas": + if (TryConvertToLong(entry, out var streamReplicas)) + options.Mqtt.StreamReplicas = checked((int)streamReplicas); + break; + case "consumer_replicas": + warnings?.Add(new InvalidOperationException( + "consumer replicas setting ignored in this server version")); + break; + case "consumer_memory_storage": + if (TryConvertToBool(entry, out var consumerMemoryStorage)) + options.Mqtt.ConsumerMemoryStorage = consumerMemoryStorage; + break; + case "consumer_inactive_threshold": + case "consumer_auto_cleanup": + options.Mqtt.ConsumerInactiveThreshold = + ParseDuration("consumer_inactive_threshold", entry, errors, warnings); + break; + case "reject_qos2_publish": + if (TryConvertToBool(entry, out var rejectQoS2Publish)) + options.Mqtt.RejectQoS2Pub = rejectQoS2Publish; + break; + case "downgrade_qos2_subscribe": + if (TryConvertToBool(entry, out var downgradeQoS2Subscribe)) + options.Mqtt.DowngradeQoS2Sub = downgradeQoS2Subscribe; + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + return null; + } + + /// + /// Parses proxy configuration block. + /// Mirrors parseProxies in opts.go. + /// + public static (ProxiesConfig? Proxies, Exception? Error) ParseProxies( + object? value, + ICollection? errors = null) + { + if (!TryGetMap(value, out var map)) + { + return (null, new InvalidOperationException( + $"expected proxies to be a map/struct, got {value?.GetType().Name ?? "null"}")); + } + + var proxies = new ProxiesConfig(); + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "trusted": + { + var (trusted, parseError) = ParseProxiesTrusted(entry, errors); + if (parseError != null) + { + errors?.Add(parseError); + break; + } + + proxies.Trusted = trusted; + break; + } + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + return (proxies, null); + } + + /// + /// Parses trusted proxy entries. + /// Mirrors parseProxiesTrusted in opts.go. + /// + public static (List Trusted, Exception? Error) ParseProxiesTrusted( + object? value, + ICollection? errors = null) + { + if (!TryGetArray(value, out var array)) + { + return ([], new InvalidOperationException( + $"expected proxies' trusted field to be an array, got {value?.GetType().Name ?? "null"}")); + } + + var trusted = new List(); + foreach (var entry in array) + { + if (!TryGetMap(entry, out var proxyMap)) + { + errors?.Add(new InvalidOperationException( + $"expected proxies' trusted entry to be a map/struct, got {entry?.GetType().Name ?? "null"}")); + continue; + } + + var proxy = new ProxyConfig(); + foreach (var (rawKey, rawValue) in proxyMap) + { + var key = rawKey.ToLowerInvariant(); + var normalized = NormalizeConfigValue(rawValue); + switch (key) + { + case "key": + case "public_key": + proxy.Key = normalized as string ?? string.Empty; + if (!IsValidNatsPublicKey(proxy.Key)) + { + errors?.Add(new InvalidOperationException($"invalid proxy key \"{proxy.Key}\"")); + } + + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + trusted.Add(proxy); + } + + return (trusted, null); + } + + /// + /// Generates runtime TLS options from parsed TLS config. + /// Mirrors GenTLSConfig in opts.go. + /// + public static (SslServerAuthenticationOptions? Config, Exception? Error) GenTLSConfig(TlsConfigOpts options) + { + ArgumentNullException.ThrowIfNull(options); + + var config = new SslServerAuthenticationOptions + { + EnabledSslProtocols = options.MinVersion == SslProtocols.Tls13 + ? SslProtocols.Tls13 + : SslProtocols.Tls12 | SslProtocols.Tls13, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck, + ClientCertificateRequired = options.Verify, + }; + + if (!string.IsNullOrEmpty(options.CertFile) && string.IsNullOrEmpty(options.KeyFile)) + return (null, new InvalidOperationException("missing 'key_file' in TLS configuration")); + if (string.IsNullOrEmpty(options.CertFile) && !string.IsNullOrEmpty(options.KeyFile)) + return (null, new InvalidOperationException("missing 'cert_file' in TLS configuration")); + + try + { + if (!string.IsNullOrEmpty(options.CertFile) && !string.IsNullOrEmpty(options.KeyFile)) + { + var certificate = X509Certificate2.CreateFromPemFile( + ExpandPath(options.CertFile), + ExpandPath(options.KeyFile)); + config.ServerCertificate = new X509Certificate2(certificate.Export(X509ContentType.Pkcs12)); + } + else if (options.Certificates.Count > 0) + { + var pair = options.Certificates[0]; + var certificate = X509Certificate2.CreateFromPemFile( + ExpandPath(pair.CertFile), + ExpandPath(pair.KeyFile)); + config.ServerCertificate = new X509Certificate2(certificate.Export(X509ContentType.Pkcs12)); + } + } + catch (Exception ex) + { + return (null, new InvalidOperationException($"error parsing X509 certificate/key pair: {ex.Message}", ex)); + } + + if (options.Ciphers.Count > 0) + { + try + { + config.CipherSuitesPolicy = new CipherSuitesPolicy(options.Ciphers); + } + catch (PlatformNotSupportedException) + { + // Some platforms do not allow explicit cipher suite policies. + } + } + + if (!string.IsNullOrEmpty(options.CaFile) && !File.Exists(ExpandPath(options.CaFile))) + return (null, new FileNotFoundException("failed to parse root ca certificate", ExpandPath(options.CaFile))); + + return (config, null); + } + + /// + /// Configures options from command-line arguments. + /// Mirrors ConfigureOptions in opts.go. + /// + public static (ServerOptions? Options, Exception? Error) ConfigureOptions( + IReadOnlyList args, + Action? printVersion = null, + Action? printHelp = null, + Action? printTLSHelp = null) + { + var flagOptions = new ServerOptions(); + var explicitBooleans = new Dictionary(StringComparer.Ordinal); + var nonFlags = new List(); + string configFile = string.Empty; + var tlsFlagsSeen = false; + var routesFlagSeen = false; + + for (var i = 0; i < args.Count; i++) + { + var rawArg = args[i]; + if (!rawArg.StartsWith('-')) + { + nonFlags.Add(rawArg); + continue; + } + + var inlineValue = default(string); + var arg = rawArg; + var equalsIndex = rawArg.IndexOf('='); + if (equalsIndex > 0) + { + arg = rawArg[..equalsIndex]; + inlineValue = rawArg[(equalsIndex + 1)..]; + } + + string? ReadValue() + { + if (!string.IsNullOrEmpty(inlineValue)) + return inlineValue; + if (i + 1 >= args.Count) + return null; + i++; + return args[i]; + } + + bool ReadBoolean(bool defaultValue = true) + { + var value = ReadValue(); + if (value == null || value.StartsWith('-')) + { + if (value != null) + i--; + return defaultValue; + } + + return bool.TryParse(value, out var parsed) ? parsed : defaultValue; + } + + switch (arg) + { + case "-h": + case "--help": + printHelp?.Invoke(); + return (null, null); + case "-v": + case "--version": + printVersion?.Invoke(); + return (null, null); + case "--help_tls": + printTLSHelp?.Invoke(); + return (null, null); + case "-p": + case "--port": + if (!int.TryParse(ReadValue(), out var port)) + return (null, new InvalidOperationException("Invalid value for port")); + flagOptions.Port = port; + break; + case "-a": + case "--addr": + case "--net": + flagOptions.Host = ReadValue() ?? string.Empty; + break; + case "--client_advertise": + flagOptions.ClientAdvertise = ReadValue() ?? string.Empty; + break; + case "-D": + case "--debug": + flagOptions.Debug = ReadBoolean(); + explicitBooleans["Debug"] = flagOptions.Debug; + break; + case "-V": + case "--trace": + flagOptions.Trace = ReadBoolean(); + explicitBooleans["Trace"] = flagOptions.Trace; + break; + case "-VV": + flagOptions.Trace = ReadBoolean(); + flagOptions.TraceVerbose = flagOptions.Trace; + explicitBooleans["Trace"] = flagOptions.Trace; + explicitBooleans["TraceVerbose"] = flagOptions.TraceVerbose; + break; + case "-DV": + flagOptions.Debug = ReadBoolean(); + flagOptions.Trace = flagOptions.Debug; + explicitBooleans["Debug"] = flagOptions.Debug; + explicitBooleans["Trace"] = flagOptions.Trace; + break; + case "-DVV": + flagOptions.Debug = ReadBoolean(); + flagOptions.Trace = flagOptions.Debug; + flagOptions.TraceVerbose = flagOptions.Debug; + explicitBooleans["Debug"] = flagOptions.Debug; + explicitBooleans["Trace"] = flagOptions.Trace; + explicitBooleans["TraceVerbose"] = flagOptions.TraceVerbose; + break; + case "-T": + case "--logtime": + flagOptions.Logtime = ReadBoolean(defaultValue: true); + explicitBooleans["Logtime"] = flagOptions.Logtime; + break; + case "--logtime_utc": + flagOptions.LogtimeUtc = ReadBoolean(); + break; + case "--user": + flagOptions.Username = ReadValue() ?? string.Empty; + break; + case "--pass": + flagOptions.Password = ReadValue() ?? string.Empty; + break; + case "--auth": + flagOptions.Authorization = ReadValue() ?? string.Empty; + break; + case "-m": + case "--http_port": + if (!int.TryParse(ReadValue(), out var httpPort)) + return (null, new InvalidOperationException("Invalid value for http_port")); + flagOptions.HttpPort = httpPort; + break; + case "--ms": + case "--https_port": + if (!int.TryParse(ReadValue(), out var httpsPort)) + return (null, new InvalidOperationException("Invalid value for https_port")); + flagOptions.HttpsPort = httpsPort; + break; + case "-c": + case "--config": + configFile = ReadValue() ?? string.Empty; + break; + case "-t": + flagOptions.CheckConfig = ReadBoolean(); + break; + case "-P": + case "--pid": + flagOptions.PidFile = ReadValue() ?? string.Empty; + break; + case "--ports_file_dir": + flagOptions.PortsFileDir = ReadValue() ?? string.Empty; + break; + case "-l": + case "--log": + flagOptions.LogFile = ReadValue() ?? string.Empty; + break; + case "--log_size_limit": + if (!long.TryParse(ReadValue(), out var logSizeLimit)) + return (null, new InvalidOperationException("Invalid value for log_size_limit")); + flagOptions.LogSizeLimit = logSizeLimit; + break; + case "-s": + case "--syslog": + flagOptions.Syslog = ReadBoolean(); + explicitBooleans["Syslog"] = flagOptions.Syslog; + break; + case "-r": + case "--remote_syslog": + flagOptions.RemoteSyslog = ReadValue() ?? string.Empty; + break; + case "--profile": + if (!int.TryParse(ReadValue(), out var profilePort)) + return (null, new InvalidOperationException("Invalid value for profile")); + flagOptions.ProfPort = profilePort; + break; + case "--routes": + flagOptions.RoutesStr = ReadValue() ?? string.Empty; + routesFlagSeen = true; + break; + case "--cluster": + case "--cluster_listen": + flagOptions.Cluster.ListenStr = ReadValue() ?? string.Empty; + break; + case "--cluster_advertise": + flagOptions.Cluster.Advertise = ReadValue() ?? string.Empty; + break; + case "--no_advertise": + flagOptions.Cluster.NoAdvertise = ReadBoolean(); + explicitBooleans["Cluster.NoAdvertise"] = flagOptions.Cluster.NoAdvertise; + break; + case "--connect_retries": + if (!int.TryParse(ReadValue(), out var connectRetries)) + return (null, new InvalidOperationException("Invalid value for connect_retries")); + flagOptions.Cluster.ConnectRetries = connectRetries; + break; + case "--cluster_name": + flagOptions.Cluster.Name = ReadValue() ?? string.Empty; + break; + case "--tls": + flagOptions.Tls = ReadBoolean(); + tlsFlagsSeen = true; + break; + case "--tlsverify": + flagOptions.TlsVerify = ReadBoolean(); + tlsFlagsSeen = true; + break; + case "--tlscert": + flagOptions.TlsCert = ReadValue() ?? string.Empty; + tlsFlagsSeen = true; + break; + case "--tlskey": + flagOptions.TlsKey = ReadValue() ?? string.Empty; + tlsFlagsSeen = true; + break; + case "--tlscacert": + flagOptions.TlsCaCert = ReadValue() ?? string.Empty; + tlsFlagsSeen = true; + break; + case "--max_traced_msg_len": + if (!int.TryParse(ReadValue(), out var maxTraceLength)) + return (null, new InvalidOperationException("Invalid value for max_traced_msg_len")); + flagOptions.MaxTracedMsgLen = maxTraceLength; + break; + case "--js": + case "--jetstream": + flagOptions.JetStream = ReadBoolean(); + explicitBooleans["JetStream"] = flagOptions.JetStream; + break; + case "--sd": + case "--store_dir": + flagOptions.StoreDir = ReadValue() ?? string.Empty; + break; + default: + nonFlags.Add(rawArg); + break; + } + } + + var (showVersion, showHelp, parseError) = NatsServer.ProcessCommandLineArgs(nonFlags.ToArray()); + if (parseError != null) + return (null, parseError); + if (showVersion) + { + printVersion?.Invoke(); + return (null, null); + } + + if (showHelp) + { + printHelp?.Invoke(); + return (null, null); + } + + FlagSnapshot = flagOptions.Clone(); + foreach (var (name, value) in explicitBooleans) + TrackExplicitVal(FlagSnapshot.InCmdLine, name, value); + + ServerOptions resolvedOptions; + if (!string.IsNullOrEmpty(configFile)) + { + resolvedOptions = ProcessConfigFile(configFile); + resolvedOptions = MergeOptions(resolvedOptions, flagOptions); + } + else + { + resolvedOptions = flagOptions; + } + + if (resolvedOptions.CheckConfig && string.IsNullOrEmpty(configFile)) + { + return (null, new InvalidOperationException( + "must specify [-c, --config] option to check configuration file syntax")); + } + + foreach (var (name, value) in explicitBooleans) + TrackExplicitVal(resolvedOptions.InCmdLine, name, value); + + if (!string.IsNullOrEmpty(resolvedOptions.Cluster.ListenStr)) + { + var clusterError = resolvedOptions.OverrideCluster(); + if (clusterError != null) + return (null, clusterError); + } + + if (routesFlagSeen) + { + resolvedOptions.Routes = string.IsNullOrEmpty(resolvedOptions.RoutesStr) + ? [] + : RoutesFromStr(resolvedOptions.RoutesStr); + } + + if (tlsFlagsSeen && resolvedOptions.Tls) + { + var tlsError = resolvedOptions.OverrideTls(); + if (tlsError != null) + return (null, tlsError); + } + + if (!string.IsNullOrEmpty(resolvedOptions.RoutesStr) && + string.IsNullOrEmpty(resolvedOptions.Cluster.ListenStr) && + string.IsNullOrEmpty(resolvedOptions.Cluster.Host) && + resolvedOptions.Cluster.Port == 0) + { + return (null, new InvalidOperationException( + "solicited routes require cluster capabilities, e.g. --cluster")); + } + + return (resolvedOptions, null); + } + // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- @@ -4019,6 +5593,32 @@ public sealed partial class ServerOptions value.Length >= 10 && value[0] == prefix; + private static bool IsValidNatsPublicKey(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + try + { + _ = NATS.NKeys.KeyPair.FromPublicKey(value.AsSpan()); + return true; + } + catch + { + return false; + } + } + + private static bool IsLikelyInsecureCipherSuite(TlsCipherSuite cipher) + { + var name = cipher.ToString(); + return name.Contains("_CBC_", StringComparison.OrdinalIgnoreCase) || + name.Contains("RC4", StringComparison.OrdinalIgnoreCase) || + name.Contains("3DES", StringComparison.OrdinalIgnoreCase) || + name.Contains("DES", StringComparison.OrdinalIgnoreCase) || + name.Contains("NULL", StringComparison.OrdinalIgnoreCase); + } + private static WriteTimeoutPolicy ParseWriteDeadlinePolicyFallback(string value, ICollection? errors) { errors?.Add(new InvalidOperationException( diff --git a/porting.db b/porting.db index d326e7a..54c82d5 100644 Binary files a/porting.db and b/porting.db differ diff --git a/reports/current.md b/reports/current.md index fadedc9..8a00891 100644 --- a/reports/current.md +++ b/reports/current.md @@ -1,6 +1,6 @@ # NATS .NET Porting Status Report -Generated: 2026-02-28 14:47:33 UTC +Generated: 2026-02-28 14:58:20 UTC ## Modules (12 total) @@ -12,10 +12,10 @@ Generated: 2026-02-28 14:47:33 UTC | Status | Count | |--------|-------| -| deferred | 2077 | +| deferred | 2057 | | n_a | 24 | | stub | 1 | -| verified | 1571 | +| verified | 1591 | ## Unit Tests (3257 total) @@ -34,4 +34,4 @@ Generated: 2026-02-28 14:47:33 UTC ## Overall Progress -**2816/6942 items complete (40.6%)** +**2836/6942 items complete (40.9%)**