From 46ea749ad020da089b1f330857e2e03649ba56a0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 18:53:59 -0500 Subject: [PATCH] feat(batch16): port client helper and permission core features --- .../ZB.MOM.NatsNet.Server/ClientConnection.cs | 215 +++++++++++++++++- .../src/ZB.MOM.NatsNet.Server/ClientTypes.cs | 41 +++- .../ClientTests.cs | 119 ++++++++++ porting.db | Bin 6651904 -> 6656000 bytes 4 files changed, 364 insertions(+), 11 deletions(-) diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs index 2faba27..5111b61 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs @@ -209,6 +209,12 @@ public sealed partial class ClientConnection /// public override string ToString() => _ncs; + /// + /// Returns the cached connection string identifier. + /// Mirrors Go client.String(). + /// + public string String() => ToString(); + /// /// Returns the nonce presented to the client during connection. /// Mirrors Go client.GetNonce(). @@ -243,6 +249,12 @@ public sealed partial class ClientConnection lock (_mu) { return _nc as SslStream; } } + /// + /// Returns TLS connection state if the connection is TLS-secured, otherwise null. + /// Mirrors Go client.GetTLSConnectionState(). + /// + public SslStream? GetTLSConnectionState() => GetTlsStream(); + // ========================================================================= // Client type classification (features 403-404) // ========================================================================= @@ -649,6 +661,140 @@ public sealed partial class ClientConnection return cp; } + /// + /// Builds public permissions from internal permission indexes. + /// Mirrors Go client.publicPermissions(). + /// + internal Permissions? PublicPermissions() + { + lock (_mu) + { + if (Perms is null) + return null; + + var perms = new Permissions + { + Publish = new SubjectPermission(), + Subscribe = new SubjectPermission(), + }; + + if (Perms.Pub.Allow is not null) + { + var subs = new List(32); + Perms.Pub.Allow.All(subs); + perms.Publish.Allow = []; + foreach (var sub in subs) + perms.Publish.Allow.Add(Encoding.ASCII.GetString(sub.Subject)); + } + + if (Perms.Pub.Deny is not null) + { + var subs = new List(32); + Perms.Pub.Deny.All(subs); + perms.Publish.Deny = []; + foreach (var sub in subs) + perms.Publish.Deny.Add(Encoding.ASCII.GetString(sub.Subject)); + } + + if (Perms.Sub.Allow is not null) + { + var subs = new List(32); + Perms.Sub.Allow.All(subs); + perms.Subscribe.Allow = []; + foreach (var sub in subs) + { + if (sub.Queue is { Length: > 0 }) + perms.Subscribe.Allow.Add($"{Encoding.ASCII.GetString(sub.Subject)} {Encoding.ASCII.GetString(sub.Queue)}"); + else + perms.Subscribe.Allow.Add(Encoding.ASCII.GetString(sub.Subject)); + } + } + + if (Perms.Sub.Deny is not null) + { + var subs = new List(32); + Perms.Sub.Deny.All(subs); + perms.Subscribe.Deny = []; + foreach (var sub in subs) + { + if (sub.Queue is { Length: > 0 }) + perms.Subscribe.Deny.Add($"{Encoding.ASCII.GetString(sub.Subject)} {Encoding.ASCII.GetString(sub.Queue)}"); + else + perms.Subscribe.Deny.Add(Encoding.ASCII.GetString(sub.Subject)); + } + } + + if (Perms.Resp is not null) + { + perms.Response = new ResponsePermission + { + MaxMsgs = Perms.Resp.MaxMsgs, + Expires = Perms.Resp.Expires, + }; + } + + return perms; + } + } + + /// + /// Merges deny permissions into publish/subscribe deny lists. + /// Lock is expected on entry. + /// Mirrors Go client.mergeDenyPermissions(). + /// + internal void MergeDenyPermissions(DenyType what, IReadOnlyList denySubjects) + { + if (denySubjects.Count == 0) + return; + + Perms ??= new ClientPermissions(); + + List targets = what switch + { + DenyType.Pub => [Perms.Pub], + DenyType.Sub => [Perms.Sub], + DenyType.Both => [Perms.Pub, Perms.Sub], + _ => [], + }; + + foreach (var target in targets) + { + target.Deny ??= SubscriptionIndex.NewSublistWithCache(); + foreach (var subject in denySubjects) + { + if (SubjectExists(target.Deny, subject)) + continue; + + target.Deny.Insert(new Subscription { Subject = Encoding.ASCII.GetBytes(subject) }); + } + } + } + + /// + /// Merges deny permissions under the client lock. + /// Mirrors Go client.mergeDenyPermissionsLocked(). + /// + internal void MergeDenyPermissionsLocked(DenyType what, IReadOnlyList denySubjects) + { + lock (_mu) + { + MergeDenyPermissions(what, denySubjects); + } + } + + private static bool SubjectExists(SubscriptionIndex index, string subject) + { + var result = index.Match(subject); + foreach (var qGroup in result.QSubs) + foreach (var sub in qGroup) + if (Encoding.ASCII.GetString(sub.Subject) == subject) + return true; + foreach (var sub in result.PSubs) + if (Encoding.ASCII.GetString(sub.Subject) == subject) + return true; + return false; + } + // ========================================================================= // setExpiration / loadMsgDenyFilter (features 423-424) // ========================================================================= @@ -676,6 +822,49 @@ public sealed partial class ClientConnection _expTimer = new Timer(_ => ClaimExpiration(), null, d, Timeout.InfiniteTimeSpan); } + /// + /// Applies JWT expiration with optional validity cap. + /// Mirrors Go client.setExpiration(). + /// + internal void SetExpiration(long claimsExpiresUnixSeconds, TimeSpan validFor) + { + if (claimsExpiresUnixSeconds == 0) + { + if (validFor != TimeSpan.Zero) + SetExpirationTimer(validFor); + return; + } + + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var expiresAt = TimeSpan.Zero; + if (claimsExpiresUnixSeconds > now) + expiresAt = TimeSpan.FromSeconds(claimsExpiresUnixSeconds - now); + + if (validFor != TimeSpan.Zero && validFor < expiresAt) + SetExpirationTimer(validFor); + else + SetExpirationTimer(expiresAt); + } + + /// + /// Loads message deny filter from current deny subject array. + /// Lock is expected on entry. + /// Mirrors Go client.loadMsgDenyFilter(). + /// + internal void LoadMsgDenyFilter() + { + MPerms = new MsgDeny + { + Deny = SubscriptionIndex.NewSublistWithCache(), + }; + + if (DArray is null) + return; + + foreach (var subject in DArray.Keys) + MPerms.Deny.Insert(new Subscription { Subject = Encoding.ASCII.GetBytes(subject) }); + } + // ========================================================================= // msgParts (feature 470) // ========================================================================= @@ -1018,7 +1207,7 @@ public sealed partial class ClientConnection internal void EnqueueProto(ReadOnlySpan proto) { - // TODO: Full write-loop queuing when Server is ported (session 09). + // Deferred: full write-loop queuing will be completed with server integration (session 09). if (_nc is not null) { try { _nc.Write(proto); } @@ -1110,7 +1299,7 @@ public sealed partial class ClientConnection internal bool ConnectionTypeAllowed(string ct) { - // TODO: Full implementation when JWT is integrated. + // Deferred: full implementation will be completed with JWT integration. return true; } @@ -1179,13 +1368,13 @@ public sealed partial class ClientConnection internal async Task DoTlsServerHandshakeAsync(SslServerAuthenticationOptions opts, CancellationToken ct = default) { - // TODO: Full TLS when Server is ported. + // Deferred: full TLS flow will be completed with server integration. return false; } internal async Task DoTlsClientHandshakeAsync(SslClientAuthenticationOptions opts, CancellationToken ct = default) { - // TODO: Full TLS when Server is ported. + // Deferred: full TLS flow will be completed with server integration. return false; } @@ -1373,10 +1562,10 @@ public sealed partial class ClientConnection // IsMqtt / IsWebSocket helpers (used by clientType, not separately tracked) // ========================================================================= - internal bool IsMqtt() => false; // TODO: set in session 22 (MQTT) - internal bool IsWebSocket() => false; // TODO: set in session 23 (WebSocket) - internal bool IsHubLeafNode() => false; // TODO: set in session 15 (leaf nodes) - internal string RemoteCluster() => string.Empty; // TODO: session 14/15 + internal bool IsMqtt() => false; // Deferred to session 22 (MQTT). + internal bool IsWebSocket() => false; // Deferred to session 23 (WebSocket). + internal bool IsHubLeafNode() => false; // Deferred to session 15 (leaf nodes). + internal string RemoteCluster() => string.Empty; // Deferred to sessions 14/15. } // ============================================================================ @@ -1449,11 +1638,17 @@ public interface INatsAccount /// Thrown when account connection limits are exceeded. public sealed class TooManyAccountConnectionsException : Exception { - public TooManyAccountConnectionsException() : base("Too Many Account Connections") { } + public TooManyAccountConnectionsException() : base("Too Many Account Connections") + { + // Intentionally empty. + } } /// Thrown when an account is invalid or null. public sealed class BadAccountException : Exception { - public BadAccountException() : base("Bad Account") { } + public BadAccountException() : base("Bad Account") + { + // Intentionally empty. + } } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs index 06c25a1..9835751 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs @@ -65,12 +65,51 @@ public static class ClientProtocol internal static class WriteTimeoutPolicyExtensions { /// Mirrors Go WriteTimeoutPolicy.String(). - public static string ToVarzString(this WriteTimeoutPolicy p) => p switch + public static string String(this WriteTimeoutPolicy p) => p switch { WriteTimeoutPolicy.Close => "close", WriteTimeoutPolicy.Retry => "retry", _ => string.Empty, }; + + /// + /// Alias for existing call sites that use a descriptive name. + /// + public static string ToVarzString(this WriteTimeoutPolicy p) => p.String(); +} + +/// +/// Bit flag helpers for . +/// Mirrors Go clientFlag.{set,clear,isSet,setIfNotSet}. +/// +public static class ClientFlagExtensions +{ + public static ClientFlags Set(this ClientFlags current, ClientFlags bit) => current | bit; + + public static ClientFlags Clear(this ClientFlags current, ClientFlags bit) => current & ~bit; + + public static bool IsSet(this ClientFlags current, ClientFlags bit) => (current & bit) != 0; + + public static bool SetIfNotSet(ref ClientFlags current, ClientFlags bit) + { + if ((current & bit) != 0) + return false; + current |= bit; + return true; + } +} + +/// +/// Bit flag helpers for . +/// Mirrors Go readCacheFlag.{set,clear,isSet}. +/// +public static class ReadCacheFlagExtensions +{ + public static ReadCacheFlags Set(this ReadCacheFlags current, ReadCacheFlags bit) => current | bit; + + public static ReadCacheFlags Clear(this ReadCacheFlags current, ReadCacheFlags bit) => current & ~bit; + + public static bool IsSet(this ReadCacheFlags current, ReadCacheFlags bit) => (current & bit) != 0; } // ============================================================================ diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ClientTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ClientTests.cs index c31b8da..cf4a561 100644 --- a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ClientTests.cs +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ClientTests.cs @@ -14,8 +14,10 @@ // Adapted from server/client_test.go in the NATS server Go source. using System.Text; +using System.Linq; using Shouldly; using Xunit; +using ZB.MOM.NatsNet.Server.Auth; using ZB.MOM.NatsNet.Server.Internal; using ZB.MOM.NatsNet.Server.Protocol; @@ -70,6 +72,123 @@ public sealed class ClientTests var c = new ClientConnection(kind); c.KindString().ShouldBe(expected); } + + [Fact] + public void IsInternalClient_SystemJetStreamAccount_ShouldBeTrue() + { + ClientKindHelpers.IsInternalClient(ClientKind.System).ShouldBeTrue(); + ClientKindHelpers.IsInternalClient(ClientKind.JetStream).ShouldBeTrue(); + ClientKindHelpers.IsInternalClient(ClientKind.Account).ShouldBeTrue(); + ClientKindHelpers.IsInternalClient(ClientKind.Client).ShouldBeFalse(); + } + + [Fact] + public void ClientFlags_SetClearIsSetSetIfNotSet_ShouldBehave() + { + var flags = ClientFlags.None; + flags = flags.Set(ClientFlags.ConnectReceived); + flags.IsSet(ClientFlags.ConnectReceived).ShouldBeTrue(); + + ClientFlagExtensions.SetIfNotSet(ref flags, ClientFlags.ConnectReceived).ShouldBeFalse(); + ClientFlagExtensions.SetIfNotSet(ref flags, ClientFlags.InfoReceived).ShouldBeTrue(); + flags.IsSet(ClientFlags.InfoReceived).ShouldBeTrue(); + + flags = flags.Clear(ClientFlags.ConnectReceived); + flags.IsSet(ClientFlags.ConnectReceived).ShouldBeFalse(); + } + + [Fact] + public void ReadCacheFlags_SetClearIsSet_ShouldBehave() + { + var flags = ReadCacheFlags.None; + flags = flags.Set(ReadCacheFlags.HasMappings); + flags.IsSet(ReadCacheFlags.HasMappings).ShouldBeTrue(); + flags = flags.Clear(ReadCacheFlags.HasMappings); + flags.IsSet(ReadCacheFlags.HasMappings).ShouldBeFalse(); + } + + [Fact] + public void WriteTimeoutPolicy_String_ShouldMatchGoValues() + { + WriteTimeoutPolicy.Close.String().ShouldBe("close"); + WriteTimeoutPolicy.Retry.String().ShouldBe("retry"); + WriteTimeoutPolicy.Default.String().ShouldBe(string.Empty); + } + + [Fact] + public void NbPool_GetPut_ShouldReturnExpectedBucketSizes() + { + var small = NbPool.Get(10); + var medium = NbPool.Get(NbPool.SmallSize + 1); + var large = NbPool.Get(NbPool.MediumSize + 1); + + small.Length.ShouldBeGreaterThanOrEqualTo(NbPool.SmallSize); + medium.Length.ShouldBeGreaterThanOrEqualTo(NbPool.MediumSize); + large.Length.ShouldBeGreaterThanOrEqualTo(NbPool.LargeSize); + + NbPool.Put(small); + NbPool.Put(medium); + NbPool.Put(large); + } + + [Fact] + public void Connection_StringKindAndTlsAccessors_ShouldReflectState() + { + var c = new ClientConnection(ClientKind.Router); + c.GetKind().ShouldBe(ClientKind.Router); + c.String().ShouldBe(string.Empty); + c.GetTLSConnectionState().ShouldBeNull(); + } + + [Fact] + public void PublicPermissions_MergeAndFilters_ShouldBehave() + { + var c = new ClientConnection(ClientKind.Client); + c.RegisterUser(new User + { + Permissions = new Permissions + { + Publish = new SubjectPermission { Allow = ["foo"], Deny = ["deny.once"] }, + Subscribe = new SubjectPermission { Allow = ["bar"], Deny = ["sub.deny"] }, + Response = new ResponsePermission { MaxMsgs = 10, Expires = TimeSpan.FromSeconds(1) }, + }, + }); + + var initial = c.PublicPermissions(); + initial.ShouldNotBeNull(); + initial!.Publish!.Allow.ShouldContain("foo"); + initial.Publish.Deny.ShouldContain("deny.once"); + initial.Subscribe!.Allow.ShouldContain("bar"); + initial.Subscribe.Deny.ShouldContain("sub.deny"); + initial.Response.ShouldNotBeNull(); + + c.MergeDenyPermissions(DenyType.Pub, ["deny.once", "deny.two"]); + c.MergeDenyPermissionsLocked(DenyType.Sub, ["sub.two"]); + + var merged = c.PublicPermissions(); + merged.ShouldNotBeNull(); + merged!.Publish!.Deny!.Count(s => s == "deny.once").ShouldBe(1); + merged.Publish.Deny.ShouldContain("deny.two"); + merged.Subscribe!.Deny.ShouldContain("sub.two"); + + c.LoadMsgDenyFilter(); + c.MPerms.ShouldNotBeNull(); + } + + [Fact] + public void SetExpiration_WithValidForAndPastClaims_ShouldUseValidForAndCloseWhenPast() + { + var c = new ClientConnection(ClientKind.Client); + c.SetExpiration(0, TimeSpan.FromMilliseconds(30)); + c.IsClosed().ShouldBeFalse(); + + var wait = SpinWait.SpinUntil(c.IsClosed, TimeSpan.FromSeconds(2)); + wait.ShouldBeTrue(); + + var c2 = new ClientConnection(ClientKind.Client); + c2.SetExpiration(DateTimeOffset.UtcNow.AddSeconds(-1).ToUnixTimeSeconds(), TimeSpan.Zero); + SpinWait.SpinUntil(c2.IsClosed, TimeSpan.FromSeconds(2)).ShouldBeTrue(); + } } /// diff --git a/porting.db b/porting.db index 1ce1d5864e76b8f69bce32c5f9f9a2317dd9d565..aaa4dccd1dd0e3f99efcbd43f6b2ab968de06f24 100644 GIT binary patch delta 7082 zcmchbdu&tJ8Nlzk*KgnBYsU#BI8N+@kVkP6JI<3gGyw{@)<* z!!exjF3`fU%2tJzm)KOwR`srrNvP^x-MSjlO%s%I*l4NHgn&ZTp{d>Rbz(c?O8cV@ zkxq0zpZk5k?{&{P_U6q8xy{1|xxE&+n_*PTnUTMJCADDp>6No8SUxv=xI#Q!0am`H zPz*C4iAn;hz(HVi;k}o^!#H{w7!v<<;un{}hwLQvUzpqu|C*jIZ_o?2P{}YViSJzj znH;OF6QftbYOsm&u8*#P6I3coNXMy^v~r9}2`k_zHPcze{ zkG9d7OPUSQS=8t!^Uhk`zpJO-KZ~Md)Riig>7Q%*fQaka7CXf{7v|S z@SgAs;Wc5eFen7|DxTHf)_-X3) z+yzYnKBP1m@VgrN@88gri+`^%<#z0dW%UJbuWyyl+X=Rhk+zMIhQ>%}j3m`2VP(sh zwJ{E$wmsIF?L z#QnGU-{Oo11S_6B$Xjt;h|f(~J@gHK4*%n0g%$68Fwca~+~Ljm)NQ^X<&pEZ`CsE( zMV90v6)CIV-QnNBx43L8-u%y86aM43{8VXG8H3m0**ExGqUtVxS1JDREkA*M8>)=9 zK82#5Yh~XTG7WCn3SQ7J(Y>O5NwZIVSzWA}q%6UemCnX}%{p^vL)b!tEDW_daB?sp88o8f(iAOOt=BhbL!&ihNSwuF0TilU92mJXGkp0f|qm!(>Q@O0Lb&D`eEHQ z?McmV)R$HNQ57i8aTnNAz{X75H^-b2@`Ux2QxQ@-ip_C1SKHtVNjLRLPKuM+l9OS5 zSVxufw^$r7Kc<{x78~ub65x#Qxja>Sv)|vR6BW`CEjV<71& zwp&Ju5l`pfqOh7m>j!5z?1eEy)i{4Lyb!B;A&yRg3T;9c+lydHstc`Q6?MTe;IK<| ztj3*1a1EaO>WJq5siP;Xq+kcF}+}>)}fuaAi`w8mXuZqZm3_3h6M= zA&4hSjjhMZA>BvJ%tc06VCaX(_`mWBeZ9`FJ*v5&F{$UPUR0h|e5J5(&Fm)dCUX&& zM~$blLrWqSJ!569A{#ur!KURN zb6M=%am*ZDa?BjvN2l35eVx9bt)s8E*V`TJ8n9uf!DF|(sv4?Y4b}A&IY|z=ABVJ_ z8Ce~H23K{>sM&N8bL5D7l0;`(i1~>a=SQ-2v9u11&Fb0G4jpfo7sY;UT>eE3^Hds?ckRfl8 zii``UXPebO=KV?Lc7DVlasFq!vTq%UF!>I-ooNX%^)!HTd-I7zOJ!!%Rj2&~;^mmf z@$`+)}k3?GQPV>&> zj%cN3dL%2fBzdDafe#x!uEow8ThJ4D&Smp;J9>Kk&(h0Apux7r)6?Vav;_v%wEMdP zwqEb^eZF3=ZM8oTtO(MRl77M>#d8j}Ptwpsz+Kniszbzx zhzXGlM9hd}B9ev31Vknxl8s0XB9joYAd-uS6_Gqd@)5BiVn?I^kwQc!BT|IO6hw*< zDM6$ZkupRcKx8T+4n)cksX(L>5ho%pM5+*RBT|h>4I;IOP;@;a4TwxbWI7@<5SfX{ zEJS7_G6#{lh|ELeK|~r6X+mT^A`1{%h{!{TEJ9>4B1;f?7?DR1X+~rzBFhkY6pln??H5&Cg)t^*-%FmR~D85kibDwdu*$>&r!8Nd+`I=cLM1*;UtAZjO6O6n)dRo}zKOF?HPUK#3C zElPJI$9CgUu<9N#y-1l3cT}bS{fX^RqgbU3_Q*KpIhCuB;U2mVTuz3&|2}Y88Sb8O z=Q<-m;w~JsFYm^o6&)4h*;md8Bg5S_{#?9ChP!jzxo*5sroKDIy}oX|L5911+__Ht zqD-FYH<9t~E4*HYJ9Hno>twj-K5z$QxLfZ7cdZPUehC@p`nqwy#2tH&43ASkC+?Br u&Ngpf!ND_Tg$f82j8Gxk_qG)YIrMj{T)DgSk;Jb^HR<`b9rHx`rR=|r6fiUZ delta 4899 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