feat(batch16): port client helper and permission core features
This commit is contained in:
@@ -209,6 +209,12 @@ public sealed partial class ClientConnection
|
||||
/// </summary>
|
||||
public override string ToString() => _ncs;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached connection string identifier.
|
||||
/// Mirrors Go <c>client.String()</c>.
|
||||
/// </summary>
|
||||
public string String() => ToString();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the nonce presented to the client during connection.
|
||||
/// Mirrors Go <c>client.GetNonce()</c>.
|
||||
@@ -243,6 +249,12 @@ public sealed partial class ClientConnection
|
||||
lock (_mu) { return _nc as SslStream; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns TLS connection state if the connection is TLS-secured, otherwise <c>null</c>.
|
||||
/// Mirrors Go <c>client.GetTLSConnectionState()</c>.
|
||||
/// </summary>
|
||||
public SslStream? GetTLSConnectionState() => GetTlsStream();
|
||||
|
||||
// =========================================================================
|
||||
// Client type classification (features 403-404)
|
||||
// =========================================================================
|
||||
@@ -649,6 +661,140 @@ public sealed partial class ClientConnection
|
||||
return cp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds public permissions from internal permission indexes.
|
||||
/// Mirrors Go <c>client.publicPermissions()</c>.
|
||||
/// </summary>
|
||||
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<Subscription>(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<Subscription>(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<Subscription>(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<Subscription>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges deny permissions into publish/subscribe deny lists.
|
||||
/// Lock is expected on entry.
|
||||
/// Mirrors Go <c>client.mergeDenyPermissions()</c>.
|
||||
/// </summary>
|
||||
internal void MergeDenyPermissions(DenyType what, IReadOnlyList<string> denySubjects)
|
||||
{
|
||||
if (denySubjects.Count == 0)
|
||||
return;
|
||||
|
||||
Perms ??= new ClientPermissions();
|
||||
|
||||
List<Perm> 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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges deny permissions under the client lock.
|
||||
/// Mirrors Go <c>client.mergeDenyPermissionsLocked()</c>.
|
||||
/// </summary>
|
||||
internal void MergeDenyPermissionsLocked(DenyType what, IReadOnlyList<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies JWT expiration with optional validity cap.
|
||||
/// Mirrors Go <c>client.setExpiration()</c>.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads message deny filter from current deny subject array.
|
||||
/// Lock is expected on entry.
|
||||
/// Mirrors Go <c>client.loadMsgDenyFilter()</c>.
|
||||
/// </summary>
|
||||
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<byte> 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<bool> 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<bool> 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
|
||||
/// <summary>Thrown when account connection limits are exceeded.</summary>
|
||||
public sealed class TooManyAccountConnectionsException : Exception
|
||||
{
|
||||
public TooManyAccountConnectionsException() : base("Too Many Account Connections") { }
|
||||
public TooManyAccountConnectionsException() : base("Too Many Account Connections")
|
||||
{
|
||||
// Intentionally empty.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when an account is invalid or null.</summary>
|
||||
public sealed class BadAccountException : Exception
|
||||
{
|
||||
public BadAccountException() : base("Bad Account") { }
|
||||
public BadAccountException() : base("Bad Account")
|
||||
{
|
||||
// Intentionally empty.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,12 +65,51 @@ public static class ClientProtocol
|
||||
internal static class WriteTimeoutPolicyExtensions
|
||||
{
|
||||
/// <summary>Mirrors Go <c>WriteTimeoutPolicy.String()</c>.</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Alias for existing call sites that use a descriptive name.
|
||||
/// </summary>
|
||||
public static string ToVarzString(this WriteTimeoutPolicy p) => p.String();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bit flag helpers for <see cref="ClientFlags"/>.
|
||||
/// Mirrors Go <c>clientFlag.{set,clear,isSet,setIfNotSet}</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bit flag helpers for <see cref="ReadCacheFlags"/>.
|
||||
/// Mirrors Go <c>readCacheFlag.{set,clear,isSet}</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
BIN
porting.db
BIN
porting.db
Binary file not shown.
Reference in New Issue
Block a user