From 12a14ec476f694557e38af3be89a5f63a4631edc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 26 Feb 2026 15:37:08 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20port=20session=2011=20=E2=80=94=20Accou?= =?UTF-8?q?nts=20&=20Directory=20JWT=20Store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Account: full Account class (200 features) with subject mappings, connection counting, export/import checks, expiration timers - DirJwtStore: directory-based JWT storage with sharding and expiry - AccountResolver: IAccountResolver, MemoryAccountResolver, UrlAccountResolver, DirAccountResolver, CacheDirAccountResolver - AccountTypes: all supporting types (AccountLimits, SConns, ExportMap, ImportMap, ServiceExport, StreamExport, ServiceLatency, etc.) - 34 unit tests (599 total), 234 features complete (IDs 150-349, 793-826) --- .../ZB.MOM.NatsNet.Server/Accounts/Account.cs | 2118 +++++++++++++++++ .../Accounts/AccountResolver.cs | 525 ++++ .../Accounts/AccountTypes.cs | 737 ++++++ .../Accounts/DirJwtStore.cs | 1373 +++++++++++ .../ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs | 44 +- .../src/ZB.MOM.NatsNet.Server/ClientTypes.cs | 7 + .../NatsServer.Accounts.cs | 11 +- .../ZB.MOM.NatsNet.Server/NatsServer.Init.cs | 2 +- .../ServerOptionTypes.cs | 15 +- .../ZB.MOM.NatsNet.Server/ServerOptions.cs | 2 +- .../Accounts/AccountTests.cs | 478 ++++ porting.db | Bin 2473984 -> 2473984 bytes reports/current.md | 12 +- reports/report_06779a1.md | 39 + 14 files changed, 5297 insertions(+), 66 deletions(-) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountResolver.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountTypes.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/AccountTests.cs create mode 100644 reports/report_06779a1.md diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs new file mode 100644 index 0000000..4a9c55a --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs @@ -0,0 +1,2118 @@ +// Copyright 2018-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/accounts.go in the NATS server Go source. + +using ZB.MOM.NatsNet.Server.Auth; +using ZB.MOM.NatsNet.Server.Internal; +using ZB.MOM.NatsNet.Server.Internal.DataStructures; + +namespace ZB.MOM.NatsNet.Server; + +// ============================================================================ +// AccountNumConns — remote server connection/leafnode count message +// Mirrors Go `AccountNumConns` struct used in updateRemoteServer. +// ============================================================================ + +/// +/// Carries the number of client connections and leaf nodes that a remote server +/// has for a given account, along with the remote server's identity. +/// Mirrors Go AccountNumConns in server/accounts.go. +/// +internal sealed class AccountNumConns +{ + /// Remote server identity. Mirrors Go Server ServerInfo. + public ServerIdentity Server { get; set; } = new(); + + /// Number of client connections on the remote server. Mirrors Go Conns int. + public int Conns { get; set; } + + /// Number of leaf nodes on the remote server. Mirrors Go LeafNodes int. + public int LeafNodes { get; set; } +} + +/// +/// Minimal remote server identity stub used by . +/// Full implementation lives with the server cluster sessions. +/// +internal sealed class ServerIdentity +{ + /// Unique server ID. Mirrors Go ID string. + public string ID { get; set; } = string.Empty; +} + +// ============================================================================ +// Account — full implementation +// Mirrors Go `Account` struct in server/accounts.go lines 52-119. +// ============================================================================ + +/// +/// Represents a NATS account, tracking clients, subscriptions, imports, exports, +/// and subject mappings. Implements so that +/// can interact with it without a hard dependency. +/// Mirrors Go Account struct in server/accounts.go. +/// +public sealed class Account : INatsAccount +{ + // ------------------------------------------------------------------------- + // Constants + // ------------------------------------------------------------------------- + + /// + /// jwt.NoLimit equivalent: -1 means no limit applied. + /// + private const int NoLimit = -1; + + // ------------------------------------------------------------------------- + // Identity fields + // ------------------------------------------------------------------------- + + /// Account name. Mirrors Go Name string. + public string Name { get; set; } = string.Empty; + + /// NKey public key. Mirrors Go Nkey string. + public string Nkey { get; set; } = string.Empty; + + /// JWT issuer key. Mirrors Go Issuer string. + public string Issuer { get; set; } = string.Empty; + + /// Raw JWT claim string. Mirrors Go claimJWT string. + internal string ClaimJwt { get; set; } = string.Empty; + + /// Time of last update from resolver. Mirrors Go updated time.Time. + internal DateTime Updated { get; set; } + + // ------------------------------------------------------------------------- + // Locks + // ------------------------------------------------------------------------- + + /// Primary account read/write lock. Mirrors Go mu sync.RWMutex. + private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.NoRecursion); + + /// Send-queue mutex. Mirrors Go sqmu sync.Mutex. + private readonly object _sqmu = new(); + + /// Leaf-node list lock. Mirrors Go lmu sync.RWMutex. + private readonly ReaderWriterLockSlim _lmu = new(LockRecursionPolicy.NoRecursion); + + /// Event ID mutex. Mirrors Go eventIdsMu sync.Mutex. + private readonly object _eventIdsMu = new(); + + /// JetStream migration/clear-observer mutex. Mirrors Go jscmMu sync.Mutex. + private readonly object _jscmMu = new(); + + // ------------------------------------------------------------------------- + // Subscription index + // ------------------------------------------------------------------------- + + /// + /// Subscription trie for this account. Mirrors Go sl *Sublist. + /// Set by the server when the account is registered. + /// + internal SubscriptionIndex? Sublist { get; set; } + + // ------------------------------------------------------------------------- + // Internal client and send queue (stubs) + // ------------------------------------------------------------------------- + + /// + /// Internal account client. Mirrors Go ic *client. + /// TODO: session 12 — full internal client wiring. + /// + internal ClientConnection? InternalClient { get; set; } + + /// + /// Send queue stub. Mirrors Go sq *sendq. + /// TODO: session 12 — send-queue implementation. + /// + internal object? SendQueue { get; set; } + + // ------------------------------------------------------------------------- + // Eventing timers + // ------------------------------------------------------------------------- + + /// Expiration timer. Mirrors Go etmr *time.Timer. + private Timer? _etmr; + + /// Connection-count timer. Mirrors Go ctmr *time.Timer. + private Timer? _ctmr; + + // ------------------------------------------------------------------------- + // Remote server tracking + // ------------------------------------------------------------------------- + + /// + /// Per-server connection and leaf-node counts. + /// Key is server ID. Mirrors Go strack map[string]sconns. + /// + private Dictionary? _strack; + + /// + /// Remote client count (sum of strack[*].Conns). + /// Mirrors Go nrclients int32. + /// Protected by . + /// + private int _nrclients; + + /// + /// System client count. + /// Mirrors Go sysclients int32. + /// Protected by . + /// + private int _sysclients; + + /// + /// Local leaf-node count. + /// Mirrors Go nleafs int32. + /// Protected by . + /// + private int _nleafs; + + /// + /// Remote leaf-node count (sum of strack[*].Leafs). + /// Mirrors Go nrleafs int32. + /// Protected by . + /// + private int _nrleafs; + + // ------------------------------------------------------------------------- + // Client set + // ------------------------------------------------------------------------- + + /// + /// Active local clients. Mirrors Go clients map[*client]struct{}. + /// Protected by . + /// + private HashSet? _clients; + + // ------------------------------------------------------------------------- + // Route and leaf-queue maps + // ------------------------------------------------------------------------- + + /// + /// Route map: subject → reference count. + /// Mirrors Go rm map[string]int32. + /// Protected by . + /// + private Dictionary? _rm; + + /// + /// Leaf queue weights: subject → weight. + /// Mirrors Go lqws map[string]int32. + /// Protected by . + /// + private Dictionary? _lqws; + + // ------------------------------------------------------------------------- + // User revocations + // ------------------------------------------------------------------------- + + /// + /// Revoked user nkeys: key → revocation timestamp (Unix seconds). + /// Mirrors Go usersRevoked map[string]int64. + /// Protected by . + /// + internal Dictionary? UsersRevoked { get; set; } + + // ------------------------------------------------------------------------- + // Subject mappings + // ------------------------------------------------------------------------- + + /// + /// Ordered list of subject mappings. Mirrors Go mappings []*mapping. + /// Protected by . + /// + private List _mappings = []; + + /// + /// Atomic flag: 1 when is non-empty. + /// Mirrors Go hasMapped atomic.Bool. + /// + private int _hasMapped; // 0 = false, 1 = true + + // ------------------------------------------------------------------------- + // Leaf nodes + // ------------------------------------------------------------------------- + + /// + /// Ordered list of local leaf-node clients. + /// Mirrors Go lleafs []*client. + /// Protected by . + /// + private List _lleafs = []; + + /// + /// Cluster name → count of leaf-node connections from that cluster. + /// Mirrors Go leafClusters map[string]uint64. + /// Protected by . + /// + private Dictionary? _leafClusters; + + // ------------------------------------------------------------------------- + // Import / export maps + // ------------------------------------------------------------------------- + + /// Import tracking. Mirrors Go imports importMap. + internal ImportMap Imports { get; set; } = new(); + + /// Export tracking. Mirrors Go exports exportMap. + internal ExportMap Exports { get; set; } = new(); + + // ------------------------------------------------------------------------- + // JetStream (stubs) + // ------------------------------------------------------------------------- + + /// + /// JetStream account state. Mirrors Go js *jsAccount. + /// TODO: session 19 — JetStream implementation. + /// + internal object? JetStream { get; set; } + + /// + /// Per-domain JetStream limits. Mirrors Go jsLimits map[string]JetStreamAccountLimits. + /// TODO: session 19 — JetStream implementation. + /// + internal Dictionary? JetStreamLimits { get; set; } + + // ------------------------------------------------------------------------- + // Misc identity fields + // ------------------------------------------------------------------------- + + /// Non-routed gateway account name. Mirrors Go nrgAccount string. + internal string NrgAccount { get; set; } = string.Empty; + + // ------------------------------------------------------------------------- + // Limits (embedded `limits` struct in Go) + // ------------------------------------------------------------------------- + + /// + /// Maximum payload size (-1 = unlimited). Mirrors Go embedded limits.mpay int32. + /// + internal int MaxPayload { get; set; } = NoLimit; + + /// + /// Maximum subscriptions (-1 = unlimited). Mirrors Go embedded limits.msubs int32. + /// + internal int MaxSubscriptions { get; set; } = NoLimit; + + /// + /// Maximum connections (-1 = unlimited). Mirrors Go embedded limits.mconns int32. + /// + internal int MaxConnections { get; set; } = NoLimit; + + /// + /// Maximum leaf nodes (-1 = unlimited). Mirrors Go embedded limits.mleafs int32. + /// + internal int MaxLeafNodes { get; set; } = NoLimit; + + /// + /// When true, bearer tokens are not allowed. + /// Mirrors Go embedded limits.disallowBearer bool. + /// + internal bool DisallowBearer { get; set; } + + // ------------------------------------------------------------------------- + // Expiration (atomic) + // ------------------------------------------------------------------------- + + /// + /// 1 when the account JWT has expired. Mirrors Go expired atomic.Bool. + /// + private int _expired; // 0 = not expired, 1 = expired + + // ------------------------------------------------------------------------- + // Miscellaneous + // ------------------------------------------------------------------------- + + /// + /// When true, this account's config could not be fully resolved. + /// Mirrors Go incomplete bool. + /// + internal bool Incomplete { get; set; } + + /// + /// Signing keys for JWT validation. + /// Mirrors Go signingKeys map[string]jwt.Scope. + /// Value is object? because JWT Scope is not yet fully ported. + /// + internal Dictionary? SigningKeys { get; set; } + + /// + /// External authorization configuration stub. + /// Mirrors Go extAuth *jwt.ExternalAuthorization. + /// TODO: session 11 — JWT full integration. + /// + internal object? ExternalAuth { get; set; } + + /// + /// The server this account is registered with, or null if not yet registered. + /// Stored as object? to avoid circular reference. + /// Mirrors Go srv *Server. + /// + internal object? Server { get; set; } + + /// + /// Loop detection subject for leaf nodes. + /// Mirrors Go lds string. + /// + internal string LoopDetectionSubject { get; set; } = string.Empty; + + /// + /// Service reply prefix (wildcard subscription root). + /// Mirrors Go siReply []byte. + /// + internal byte[]? ServiceImportReply { get; set; } + + /// + /// Subscription ID counter for internal use. + /// Mirrors Go isid uint64. + /// + private ulong _isid; + + /// + /// Default permissions for users with no explicit permissions. + /// Mirrors Go defaultPerms *Permissions. + /// + internal Permissions? DefaultPerms { get; set; } + + /// + /// Account tags from JWT. Mirrors Go tags jwt.TagList. + /// Stored as string array pending full JWT integration. + /// + internal string[] Tags { get; set; } = []; + + /// + /// Human-readable name tag (distinct from ). + /// Mirrors Go nameTag string. + /// + internal string NameTag { get; set; } = string.Empty; + + /// + /// Unix-nanosecond timestamp of last max-subscription-limit log. + /// Mirrors Go lastLimErr int64. + /// + private long _lastLimErr; + + /// + /// Route pool index (-1 = dedicated, -2 = transitioning, ≥ 0 = shared). + /// Mirrors Go routePoolIdx int. + /// + internal int RoutePoolIdx { get; set; } + + /// + /// Message-tracing destination subject. + /// Mirrors Go traceDest string. + /// Protected by . + /// + private string _traceDest = string.Empty; + + /// + /// Tracing sampling percentage (0 = header-triggered, 1-100 = rate). + /// Mirrors Go traceDestSampling int. + /// Protected by . + /// + private int _traceDestSampling; + + // ------------------------------------------------------------------------- + // Factory + // ------------------------------------------------------------------------- + + /// + /// Creates a new unlimited account with the given name. + /// Mirrors Go NewAccount(name string) *Account. + /// + public static Account NewAccount(string name) => + new() + { + Name = name, + MaxPayload = NoLimit, + MaxSubscriptions = NoLimit, + MaxConnections = NoLimit, + MaxLeafNodes = NoLimit, + }; + + // ------------------------------------------------------------------------- + // Object overrides + // ------------------------------------------------------------------------- + + /// + /// Returns the account name. Mirrors Go (a *Account) String() string. + /// + public override string ToString() => Name; + + // ------------------------------------------------------------------------- + // Shallow copy for config reload + // ------------------------------------------------------------------------- + + /// + /// Copies identity and config fields from the options-struct account (a) + /// into the live server account (na). The write lock on na must + /// be held by the caller; this (the options account) requires no lock. + /// Mirrors Go (a *Account) shallowCopy(na *Account). + /// + internal void ShallowCopy(Account na) + { + na.Nkey = Nkey; + na.Issuer = Issuer; + na._traceDest = _traceDest; + na._traceDestSampling = _traceDestSampling; + na.NrgAccount = NrgAccount; + + // Stream imports — shallow-clone each entry. + if (Imports.Streams != null) + { + na.Imports.Streams = new List(Imports.Streams.Count); + foreach (var si in Imports.Streams) + { + // Struct-style shallow copy via record-style clone. + na.Imports.Streams.Add(new StreamImportEntry + { + Account = si.Account, + From = si.From, + To = si.To, + Transform = si.Transform, + ReverseTransform = si.ReverseTransform, + Claim = si.Claim, + UsePublishedSubject = si.UsePublishedSubject, + Invalid = si.Invalid, + AllowTrace = si.AllowTrace, + }); + } + } + + // Service imports — shallow-clone each inner list. + if (Imports.Services != null) + { + na.Imports.Services = new Dictionary>(Imports.Services.Count); + foreach (var (k, list) in Imports.Services) + { + var cloned = new List(list.Count); + foreach (var si in list) + { + cloned.Add(new ServiceImportEntry + { + Account = si.Account, + Claim = si.Claim, + ServiceExport = si.ServiceExport, + SubscriptionId = si.SubscriptionId, + From = si.From, + To = si.To, + Transform = si.Transform, + Timestamp = si.Timestamp, + ResponseType = si.ResponseType, + Latency = si.Latency, + M1 = si.M1, + RequestingClient = si.RequestingClient, + UsePublishedSubject = si.UsePublishedSubject, + IsResponse = si.IsResponse, + Invalid = si.Invalid, + Share = si.Share, + Tracking = si.Tracking, + DidDeliver = si.DidDeliver, + AllowTrace = si.AllowTrace, + TrackingHeader = si.TrackingHeader, + }); + } + na.Imports.Services[k] = cloned; + } + } + + // Stream exports — shallow-clone each entry. + if (Exports.Streams != null) + { + na.Exports.Streams = new Dictionary(Exports.Streams.Count); + foreach (var (k, se) in Exports.Streams) + { + na.Exports.Streams[k] = se == null ? null! : new StreamExport + { + TokenRequired = se.TokenRequired, + AccountPosition = se.AccountPosition, + Approved = se.Approved, + ActivationsRevoked = se.ActivationsRevoked, + }; + } + } + + // Service exports — shallow-clone each entry. + if (Exports.Services != null) + { + na.Exports.Services = new Dictionary(Exports.Services.Count); + foreach (var (k, se) in Exports.Services) + { + na.Exports.Services[k] = se == null ? null! : new ServiceExportEntry + { + Account = se.Account, + ResponseType = se.ResponseType, + Latency = se.Latency, + ResponseTimer = se.ResponseTimer, + ResponseThreshold = se.ResponseThreshold, + AllowTrace = se.AllowTrace, + TokenRequired = se.TokenRequired, + AccountPosition = se.AccountPosition, + Approved = se.Approved, + ActivationsRevoked = se.ActivationsRevoked, + }; + } + } + + // Mappings and limits — copy by reference / value. + na._mappings = _mappings; + Interlocked.Exchange(ref na._hasMapped, _mappings.Count > 0 ? 1 : 0); + + // JetStream limits — shared reference. + // TODO: session 19 — deep copy JetStream limits when ported. + na.JetStreamLimits = JetStreamLimits; + + // Server-config account limits. + na.MaxPayload = MaxPayload; + na.MaxSubscriptions = MaxSubscriptions; + na.MaxConnections = MaxConnections; + na.MaxLeafNodes = MaxLeafNodes; + na.DisallowBearer = DisallowBearer; + } + + // ------------------------------------------------------------------------- + // Event ID generation + // ------------------------------------------------------------------------- + + /// + /// Generates a unique event identifier using its own dedicated lock. + /// Mirrors Go (a *Account) nextEventID() string. + /// + internal string NextEventId() + { + lock (_eventIdsMu) + { + return Guid.NewGuid().ToString("N"); + } + } + + // ------------------------------------------------------------------------- + // Client accessors + // ------------------------------------------------------------------------- + + /// + /// Returns a snapshot list of clients. Lock must be held by the caller. + /// Mirrors Go (a *Account) getClientsLocked() []*client. + /// + internal List GetClientsLocked() + { + if (_clients == null || _clients.Count == 0) + return []; + + return [.. _clients]; + } + + /// + /// Returns a thread-safe snapshot list of clients. + /// Mirrors Go (a *Account) getClients() []*client. + /// + internal List GetClients() + { + _mu.EnterReadLock(); + try + { + return GetClientsLocked(); + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Returns a snapshot of non-internal clients. Lock must be held by the caller. + /// Mirrors Go (a *Account) getExternalClientsLocked() []*client. + /// + internal List GetExternalClientsLocked() + { + if (_clients == null || _clients.Count == 0) + return []; + + var result = new List(_clients.Count); + foreach (var c in _clients) + { + if (!IsInternalClientKind(c.Kind)) + result.Add(c); + } + return result; + } + + // ------------------------------------------------------------------------- + // Remote server tracking + // ------------------------------------------------------------------------- + + /// + /// Updates the remote-server tracking table for this account based on an + /// incoming message, and returns the set of + /// local clients that must be disconnected because a connection limit has + /// been exceeded (after accounting for remote connections). + /// Mirrors Go (a *Account) updateRemoteServer(m *AccountNumConns) []*client. + /// + internal List UpdateRemoteServer(AccountNumConns m) + { + _mu.EnterWriteLock(); + try + { + _strack ??= new Dictionary(); + + _strack.TryGetValue(m.Server.ID, out var prev); + _strack[m.Server.ID] = new SConns + { + Conns = m.Conns, + Leafs = m.LeafNodes, + }; + + _nrclients += m.Conns - (prev?.Conns ?? 0); + _nrleafs += m.LeafNodes - (prev?.Leafs ?? 0); + + var localCount = _clients?.Count ?? 0; + + // Check if total connections exceed the limit. + bool maxConnsExceeded = MaxConnections != NoLimit && + (localCount - _sysclients + _nrclients) > MaxConnections; + + List toDisconnect = []; + + if (maxConnsExceeded) + { + var external = GetExternalClientsLocked(); + + // Sort: newest connections first (reverse chronological). + // TODO: session 12 — sort by c.Start once ClientConnection has a Start field. + // For now we cannot sort without the start time, so take from end. + + int over = (localCount - _sysclients + _nrclients) - MaxConnections; + if (over < external.Count) + toDisconnect.AddRange(external.GetRange(0, over)); + else + toDisconnect.AddRange(external); + } + + // Check if total leaf nodes exceed the limit. + bool maxLeafsExceeded = MaxLeafNodes != NoLimit && + (_nleafs + _nrleafs) > MaxLeafNodes; + + if (maxLeafsExceeded) + { + _lmu.EnterReadLock(); + try + { + int over = _nleafs + _nrleafs - MaxLeafNodes; + if (over > 0) + { + int start = Math.Max(0, _lleafs.Count - over); + toDisconnect.AddRange(_lleafs.GetRange(start, _lleafs.Count - start)); + } + } + finally + { + _lmu.ExitReadLock(); + } + } + + return toDisconnect; + } + finally + { + _mu.ExitWriteLock(); + } + } + + /// + /// Removes tracking for a remote server that has shut down. + /// Mirrors Go (a *Account) removeRemoteServer(sid string). + /// + internal void RemoveRemoteServer(string sid) + { + _mu.EnterWriteLock(); + try + { + if (_strack != null && _strack.TryGetValue(sid, out var prev)) + { + _strack.Remove(sid); + _nrclients -= prev.Conns; + _nrleafs -= prev.Leafs; + } + } + finally + { + _mu.ExitWriteLock(); + } + } + + /// + /// Returns the number of remote servers that have at least one connection or + /// leaf-node for this account. + /// Mirrors Go (a *Account) expectedRemoteResponses() int32. + /// + internal int ExpectedRemoteResponses() + { + int expected = 0; + _mu.EnterReadLock(); + try + { + if (_strack != null) + { + foreach (var sc in _strack.Values) + { + if (sc.Conns > 0 || sc.Leafs > 0) + expected++; + } + } + } + finally + { + _mu.ExitReadLock(); + } + return expected; + } + + // ------------------------------------------------------------------------- + // Eventing + // ------------------------------------------------------------------------- + + /// + /// Clears eventing state including timers, clients, and remote tracking. + /// Mirrors Go (a *Account) clearEventing(). + /// + internal void ClearEventing() + { + _mu.EnterWriteLock(); + try + { + _nrclients = 0; + ClearTimerLocked(ref _etmr); + ClearTimerLocked(ref _ctmr); + _clients = null; + _strack = null; + } + finally + { + _mu.ExitWriteLock(); + } + } + + // ------------------------------------------------------------------------- + // Name accessors + // ------------------------------------------------------------------------- + + /// + /// Returns the account name, thread-safely. + /// Mirrors Go (a *Account) GetName() string. + /// + public string GetName() + { + _mu.EnterReadLock(); + try + { + return Name; + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Returns the if set, otherwise . + /// Acquires a read lock. + /// Mirrors Go (a *Account) getNameTag() string. + /// + internal string GetNameTag() + { + _mu.EnterReadLock(); + try + { + return GetNameTagLocked(); + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Returns the if set, otherwise . + /// Lock must be held by the caller. + /// Mirrors Go (a *Account) getNameTagLocked() string. + /// + internal string GetNameTagLocked() => + string.IsNullOrEmpty(NameTag) ? Name : NameTag; + + // ------------------------------------------------------------------------- + // Connection counts + // ------------------------------------------------------------------------- + + /// + /// Returns the total number of active clients across all servers (local minus + /// system accounts plus remote). + /// Mirrors Go (a *Account) NumConnections() int. + /// + public int NumConnections() + { + _mu.EnterReadLock(); + try + { + return (_clients?.Count ?? 0) - _sysclients + _nrclients; + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Returns the number of client and leaf-node connections that are on + /// remote servers. + /// Mirrors Go (a *Account) NumRemoteConnections() int. + /// + public int NumRemoteConnections() + { + _mu.EnterReadLock(); + try + { + return _nrclients + _nrleafs; + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Returns the number of non-system, non-leaf clients on this server. + /// Mirrors Go (a *Account) NumLocalConnections() int. + /// + public int NumLocalConnections() + { + _mu.EnterReadLock(); + try + { + return NumLocalConnectionsLocked(); + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Returns local non-system, non-leaf client count. Lock must be held. + /// Mirrors Go (a *Account) numLocalConnections() int. + /// + internal int NumLocalConnectionsLocked() => + (_clients?.Count ?? 0) - _sysclients - _nleafs; + + /// + /// Returns all local connections including leaf nodes (minus system clients). + /// Mirrors Go (a *Account) numLocalAndLeafConnections() int. + /// + internal int NumLocalAndLeafConnections() + { + _mu.EnterReadLock(); + try + { + return (_clients?.Count ?? 0) - _sysclients; + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Returns the local leaf-node count. + /// Mirrors Go (a *Account) numLocalLeafNodes() int. + /// + internal int NumLocalLeafNodes() => _nleafs; + + // ------------------------------------------------------------------------- + // Connection limit checks + // ------------------------------------------------------------------------- + + /// + /// Returns true if the total (local + remote) client count has reached or + /// exceeded the configured limit. + /// Mirrors Go (a *Account) MaxTotalConnectionsReached() bool. + /// + public bool MaxTotalConnectionsReached() + { + _mu.EnterReadLock(); + try + { + if (MaxConnections == NoLimit) return false; + return (_clients?.Count ?? 0) - _sysclients + _nrclients >= MaxConnections; + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Returns the configured maximum connections limit. + /// Mirrors Go (a *Account) MaxActiveConnections() int. + /// + public int MaxActiveConnections() + { + _mu.EnterReadLock(); + try { return MaxConnections; } + finally { _mu.ExitReadLock(); } + } + + // ------------------------------------------------------------------------- + // Leaf-node limit checks + // ------------------------------------------------------------------------- + + /// + /// Returns true if the total (local + remote) leaf-node count has reached or + /// exceeded the configured limit. + /// Mirrors Go (a *Account) MaxTotalLeafNodesReached() bool. + /// + public bool MaxTotalLeafNodesReached() + { + _mu.EnterReadLock(); + try { return MaxTotalLeafNodesReachedLocked(); } + finally { _mu.ExitReadLock(); } + } + + /// + /// Lock must be held by the caller. + /// Mirrors Go (a *Account) maxTotalLeafNodesReached() bool. + /// + internal bool MaxTotalLeafNodesReachedLocked() + { + if (MaxLeafNodes == NoLimit) return false; + return _nleafs + _nrleafs >= MaxLeafNodes; + } + + /// + /// Returns the total leaf-node count (local + remote). + /// Mirrors Go (a *Account) NumLeafNodes() int. + /// + public int NumLeafNodes() + { + _mu.EnterReadLock(); + try { return _nleafs + _nrleafs; } + finally { _mu.ExitReadLock(); } + } + + /// + /// Returns the remote leaf-node count. + /// Mirrors Go (a *Account) NumRemoteLeafNodes() int. + /// + public int NumRemoteLeafNodes() + { + _mu.EnterReadLock(); + try { return _nrleafs; } + finally { _mu.ExitReadLock(); } + } + + /// + /// Returns the configured maximum leaf-nodes limit. + /// Mirrors Go (a *Account) MaxActiveLeafNodes() int. + /// + public int MaxActiveLeafNodes() + { + _mu.EnterReadLock(); + try { return MaxLeafNodes; } + finally { _mu.ExitReadLock(); } + } + + // ------------------------------------------------------------------------- + // Subscription counts + // ------------------------------------------------------------------------- + + /// + /// Returns the number of route-map entries (subjects sent across routes). + /// Mirrors Go (a *Account) RoutedSubs() int. + /// + public int RoutedSubs() + { + _mu.EnterReadLock(); + try { return _rm?.Count ?? 0; } + finally { _mu.ExitReadLock(); } + } + + /// + /// Returns the total number of subscriptions in this account's subscription index. + /// Mirrors Go (a *Account) TotalSubs() int. + /// + public int TotalSubs() + { + _mu.EnterReadLock(); + try + { + if (Sublist == null) return 0; + return (int)Sublist.Count(); + } + finally + { + _mu.ExitReadLock(); + } + } + + // ------------------------------------------------------------------------- + // Subscription limit error throttle + // ------------------------------------------------------------------------- + + /// + /// Returns true when it is appropriate to log a max-subscription-limit error. + /// Rate-limited to at most once per . + /// Mirrors Go (a *Account) shouldLogMaxSubErr() bool. + /// + internal bool ShouldLogMaxSubErr() + { + _mu.EnterReadLock(); + long last = Interlocked.Read(ref _lastLimErr); + _mu.ExitReadLock(); + + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L; // nanoseconds + long threshold = (long)AccountEventConstants.DefaultMaxSubLimitReportThreshold.TotalMilliseconds * 1_000_000L; + + if (now - last < threshold) + return false; + + _mu.EnterWriteLock(); + try + { + Interlocked.Exchange(ref _lastLimErr, now); + } + finally + { + _mu.ExitWriteLock(); + } + return true; + } + + // ------------------------------------------------------------------------- + // Expiration + // ------------------------------------------------------------------------- + + /// + /// Returns true when the account JWT has expired. + /// Mirrors Go (a *Account) IsExpired() bool. + /// + public bool IsExpired() => + Interlocked.CompareExchange(ref _expired, 0, 0) == 1; + + /// + /// Returns true when this account is backed by a JWT claim. + /// Lock must be held by the caller. + /// Mirrors Go (a *Account) isClaimAccount() bool. + /// + internal bool IsClaimAccount() => + !string.IsNullOrEmpty(ClaimJwt); + + /// + /// Invoked when the expiration timer fires: marks expired and collects clients. + /// Mirrors Go (a *Account) expiredTimeout(). + /// + private void ExpiredTimeout() + { + Interlocked.Exchange(ref _expired, 1); + + var clients = GetClients(); + foreach (var c in clients) + { + if (!IsInternalClientKind(c.Kind)) + { + // TODO: session 12 — call c.AccountAuthExpired() once fully ported. + } + } + } + + /// + /// Starts or resets the JWT expiration timer. + /// Mirrors Go (a *Account) setExpirationTimer(d time.Duration). + /// + internal void SetExpirationTimer(TimeSpan d) + { + _etmr = new Timer(_ => ExpiredTimeout(), null, d, Timeout.InfiniteTimeSpan); + } + + /// + /// Stops the expiration timer. Returns true if it was active. + /// Lock must be held by the caller. + /// Mirrors Go (a *Account) clearExpirationTimer() bool. + /// + internal bool ClearExpirationTimer() + { + if (_etmr == null) + return true; + + _etmr.Dispose(); + _etmr = null; + return true; + } + + // ------------------------------------------------------------------------- + // Subject mappings — public API + // ------------------------------------------------------------------------- + + /// + /// Adds a simple 1:1 subject mapping from to + /// with weight 100. + /// Mirrors Go (a *Account) AddMapping(src, dest string) error. + /// + public Exception? AddMapping(string src, string dest) => + AddWeightedMappings(src, MapDest.New(dest, 100)); + + /// + /// Adds weighted subject mappings for one or more destinations. + /// Total weights must not exceed 100 per cluster group. If the total is + /// less than 100 and the source was not listed as a destination, the + /// remainder is automatically routed back to the source. + /// Weights are converted to cumulative form and sorted ascending so that + /// random selection can use a single linear scan. + /// Mirrors Go (a *Account) AddWeightedMappings(src string, dests ...*MapDest) error. + /// + public Exception? AddWeightedMappings(string src, params MapDest[] dests) + { + _mu.EnterWriteLock(); + try + { + if (!SubscriptionIndex.IsValidSubject(src)) + return ServerErrors.ErrBadSubject; + + bool hasWildcard = SubscriptionIndex.SubjectHasWildcard(src); + var m = new SubjectMapping + { + Source = src, + HasWildcard = hasWildcard, + Destinations = new List(dests.Length + 1), + }; + + var seen = new HashSet(dests.Length); + var totals = new Dictionary(); // cluster → cumulative weight + + foreach (var d in dests) + { + if (!seen.Add(d.Subject)) + return new InvalidOperationException($"duplicate entry for \"{d.Subject}\""); + + if (d.Weight > 100) + return new InvalidOperationException("individual weights need to be <= 100"); + + totals.TryGetValue(d.Cluster, out byte tw); + int next = tw + d.Weight; + if (next > 100) + return new InvalidOperationException("total weight needs to be <= 100"); + totals[d.Cluster] = (byte)next; + + // Validate the transform is valid. + var validateErr = ValidateMapping(src, d.Subject); + if (validateErr != null) + return validateErr; + + var (tr, trErr) = SubjectTransform.New(src, d.Subject); + if (trErr != null) + return trErr; + + if (string.IsNullOrEmpty(d.Cluster)) + { + m.Destinations.Add(new Destination { Transform = tr, Weight = d.Weight }); + } + else + { + m.ClusterDestinations ??= new Dictionary>(); + if (!m.ClusterDestinations.TryGetValue(d.Cluster, out var clusterList)) + { + clusterList = []; + m.ClusterDestinations[d.Cluster] = clusterList; + } + clusterList.Add(new Destination { Transform = tr, Weight = d.Weight }); + } + } + + // Process each destination list: fill remainder and convert to cumulative weights. + var destErr = ProcessDestinations(src, hasWildcard, seen, m.Destinations); + if (destErr != null) return destErr; + + if (m.ClusterDestinations != null) + { + var clusterKeys = new List(m.ClusterDestinations.Keys); + foreach (var cluster in clusterKeys) + { + destErr = ProcessDestinations(src, hasWildcard, seen, m.ClusterDestinations[cluster]); + if (destErr != null) return destErr; + } + } + + // Replace existing entry for the same source, or append. + for (int i = 0; i < _mappings.Count; i++) + { + if (_mappings[i].Source == src) + { + _mappings[i] = m; + return null; + } + } + + _mappings.Add(m); + Interlocked.Exchange(ref _hasMapped, _mappings.Count > 0 ? 1 : 0); + + // TODO: session 15 — notify connected leaf nodes via lc.ForceAddToSmap(src). + + return null; + } + finally + { + _mu.ExitWriteLock(); + } + } + + /// + /// Removes a subject mapping entry by source subject. + /// Returns true if an entry was removed. + /// Mirrors Go (a *Account) RemoveMapping(src string) bool. + /// + public bool RemoveMapping(string src) + { + _mu.EnterWriteLock(); + try + { + for (int i = 0; i < _mappings.Count; i++) + { + if (_mappings[i].Source == src) + { + // Swap with last element to avoid shifting (order may change). + _mappings[i] = _mappings[^1]; + _mappings.RemoveAt(_mappings.Count - 1); + Interlocked.Exchange(ref _hasMapped, _mappings.Count > 0 ? 1 : 0); + + // TODO: session 15 — notify leaf nodes via lc.ForceRemoveFromSmap(src). + return true; + } + } + return false; + } + finally + { + _mu.ExitWriteLock(); + } + } + + /// + /// Returns true when there is at least one subject mapping entry. + /// Mirrors Go (a *Account) hasMappings() bool. + /// + internal bool HasMappings() => + Interlocked.CompareExchange(ref _hasMapped, 0, 0) == 1; + + /// + /// Selects a mapped destination subject using weighted random selection. + /// Returns (, false) when no mapping matches. + /// Mirrors Go (a *Account) selectMappedSubject(dest string) (string, bool). + /// + internal (string dest, bool mapped) SelectMappedSubject(string dest) + { + if (!HasMappings()) + return (dest, false); + + _mu.EnterReadLock(); + try + { + // Tokenise the destination for wildcard subset matching. + string[]? tts = null; + + SubjectMapping? m = null; + foreach (var rm in _mappings) + { + if (!rm.HasWildcard && rm.Source == dest) + { + m = rm; + break; + } + + // Lazy tokenise for subset matching. + tts ??= TokenizeSubjectForMapping(dest); + + if (SubjectTransform.IsSubsetMatch(tts, rm.Source)) + { + m = rm; + break; + } + } + + if (m == null) + return (dest, false); + + // Select the destination list (cluster-scoped or global). + List dests = m.Destinations; + if (m.ClusterDestinations != null && m.ClusterDestinations.Count > 0) + { + string clusterName = GetCachedClusterName(); + if (!string.IsNullOrEmpty(clusterName) && + m.ClusterDestinations.TryGetValue(clusterName, out var cdests)) + { + dests = cdests; + } + } + + if (dests.Count == 0) + return (dest, false); + + // Optimise single-entry case where the full weight is 100. + Destination? selected = null; + if (dests.Count == 1 && dests[0].Weight == 100) + { + selected = dests[0]; + } + else + { + byte w = (byte)(Random.Shared.Next() % 100); + foreach (var d in dests) + { + if (w < d.Weight) + { + selected = d; + break; + } + } + } + + if (selected == null) + return (dest, false); + + string ndest; + if (selected.Transform == null) + { + ndest = dest; + } + else if (tts != null) + { + ndest = selected.Transform.TransformTokenizedSubject(tts); + } + else + { + ndest = selected.Transform.TransformSubject(dest); + } + + return (ndest, true); + } + finally + { + _mu.ExitReadLock(); + } + } + + // ------------------------------------------------------------------------- + // Export checks + // ------------------------------------------------------------------------- + + /// + /// Returns true if the given service subject is exported (exact or wildcard match). + /// Mirrors Go (a *Account) IsExportService(service string) bool. + /// + public bool IsExportService(string service) + { + _mu.EnterReadLock(); + try + { + if (Exports.Services == null) + return false; + + if (Exports.Services.ContainsKey(service)) + return true; + + var tokens = SubjectTransform.TokenizeSubject(service); + foreach (var subj in Exports.Services.Keys) + { + if (SubjectTransform.IsSubsetMatch(tokens, subj)) + return true; + } + return false; + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Returns true if the given service export has latency tracking enabled. + /// Mirrors Go (a *Account) IsExportServiceTracking(service string) bool. + /// + public bool IsExportServiceTracking(string service) + { + _mu.EnterReadLock(); + try + { + if (Exports.Services == null) + return false; + + if (Exports.Services.TryGetValue(service, out var ea)) + { + if (ea == null) return false; + if (ea.Latency != null) return true; + } + + var tokens = SubjectTransform.TokenizeSubject(service); + foreach (var (subj, se) in Exports.Services) + { + if (SubjectTransform.IsSubsetMatch(tokens, subj) && se?.Latency != null) + return true; + } + return false; + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Checks whether another account is approved to import this stream export. + /// Lock must be held on entry (read is sufficient). + /// Mirrors Go (a *Account) checkStreamExportApproved(...) bool. + /// + internal bool CheckStreamExportApproved(Account account, string subject, object? imClaim) + { + if (Exports.Streams == null) return false; + + if (Exports.Streams.TryGetValue(subject, out var ea)) + { + if (ea == null) return true; + return CheckAuth(ea, account, imClaim, null); + } + + var tokens = SubjectTransform.TokenizeSubject(subject); + foreach (var (subj, se) in Exports.Streams) + { + if (SubjectTransform.IsSubsetMatch(tokens, subj)) + { + if (se == null) return true; + return CheckAuth(se, account, imClaim, tokens); + } + } + return false; + } + + /// + /// Checks whether another account is approved to import this service export. + /// Lock must be held on entry (read is sufficient). + /// Mirrors Go (a *Account) checkServiceExportApproved(...) bool. + /// + internal bool CheckServiceExportApproved(Account account, string subject, object? imClaim) + { + if (Exports.Services == null) return false; + + if (Exports.Services.TryGetValue(subject, out var se)) + { + if (se == null) return true; + return CheckAuth(se, account, imClaim, null); + } + + var tokens = SubjectTransform.TokenizeSubject(subject); + foreach (var (subj, entry) in Exports.Services) + { + if (SubjectTransform.IsSubsetMatch(tokens, subj)) + { + if (entry == null) return true; + return CheckAuth(entry, account, imClaim, tokens); + } + } + return false; + } + + // ------------------------------------------------------------------------- + // User revocation check + // ------------------------------------------------------------------------- + + /// + /// Returns true if the user identified by with the + /// given timestamp has been revoked. + /// Mirrors Go (a *Account) checkUserRevoked(nkey string, issuedAt int64) bool. + /// + public bool CheckUserRevoked(string nkey, long issuedAt) + { + _mu.EnterReadLock(); + try + { + return IsRevoked(UsersRevoked, nkey, issuedAt); + } + finally + { + _mu.ExitReadLock(); + } + } + + // ------------------------------------------------------------------------- + // Config-reload comparison helpers + // ------------------------------------------------------------------------- + + /// + /// Returns true if this account's stream imports equal 's. + /// Acquires this account's read lock; must not be + /// concurrently accessed. + /// Mirrors Go (a *Account) checkStreamImportsEqual(b *Account) bool. + /// + internal bool CheckStreamImportsEqual(Account b) + { + _mu.EnterReadLock(); + try + { + var aStreams = Imports.Streams; + var bStreams = b.Imports.Streams; + + int aLen = aStreams?.Count ?? 0; + int bLen = bStreams?.Count ?? 0; + if (aLen != bLen) return false; + if (aLen == 0) return true; + + // Build an index from (accName+from+to) → entry for b. + var bIndex = new Dictionary(bLen); + foreach (var bim in bStreams!) + { + string key = (bim.Account?.Name ?? string.Empty) + bim.From + bim.To; + bIndex[key] = bim; + } + + foreach (var aim in aStreams!) + { + string key = (aim.Account?.Name ?? string.Empty) + aim.From + aim.To; + if (!bIndex.TryGetValue(key, out var bim)) + return false; + if (aim.AllowTrace != bim.AllowTrace) + return false; + } + return true; + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Returns true if this account's stream exports equal 's. + /// Acquires this account's read lock; must not be + /// concurrently accessed. + /// Mirrors Go (a *Account) checkStreamExportsEqual(b *Account) bool. + /// + internal bool CheckStreamExportsEqual(Account b) + { + _mu.EnterReadLock(); + try + { + var aStreams = Exports.Streams; + var bStreams = b.Exports.Streams; + + int aLen = aStreams?.Count ?? 0; + int bLen = bStreams?.Count ?? 0; + if (aLen != bLen) return false; + if (aLen == 0) return true; + + foreach (var (subj, aea) in aStreams!) + { + if (!bStreams!.TryGetValue(subj, out var bea)) + return false; + if (!IsStreamExportEqual(aea, bea)) + return false; + } + return true; + } + finally + { + _mu.ExitReadLock(); + } + } + + /// + /// Returns true if this account's service exports equal 's. + /// Acquires this account's read lock; must not be + /// concurrently accessed. + /// Mirrors Go (a *Account) checkServiceExportsEqual(b *Account) bool. + /// + internal bool CheckServiceExportsEqual(Account b) + { + _mu.EnterReadLock(); + try + { + var aServices = Exports.Services; + var bServices = b.Exports.Services; + + int aLen = aServices?.Count ?? 0; + int bLen = bServices?.Count ?? 0; + if (aLen != bLen) return false; + if (aLen == 0) return true; + + foreach (var (subj, aea) in aServices!) + { + if (!bServices!.TryGetValue(subj, out var bea)) + return false; + if (!IsServiceExportEqual(aea, bea)) + return false; + } + return true; + } + finally + { + _mu.ExitReadLock(); + } + } + + // ------------------------------------------------------------------------- + // Leaf-node helpers + // ------------------------------------------------------------------------- + + /// + /// Notifies leaf nodes of a subscription change. + /// Stub — full implementation in session 15. + /// Mirrors Go (a *Account) updateLeafNodes(sub, delta). + /// + internal void UpdateLeafNodes(object sub, int delta) + { + // TODO: session 15 — leaf node subscription propagation. + } + + // ------------------------------------------------------------------------- + // addClient / removeClient + // ------------------------------------------------------------------------- + + /// + /// Registers a client with this account, updating system and leaf counters. + /// Returns the previous total client count. + /// Mirrors Go (a *Account) addClient(c *client) int. + /// + private int AddClientInternal(ClientConnection c) + { + _mu.EnterWriteLock(); + int prev; + try + { + _clients ??= new HashSet(); + prev = _clients.Count; + + if (!_clients.Add(c)) + { + // Client was already present — do nothing. + return prev; + } + + if (IsInternalClientKind(c.Kind)) + { + _sysclients++; + } + else if (c.Kind == ClientKind.Leaf) + { + _nleafs++; + } + } + finally + { + _mu.ExitWriteLock(); + } + + // Add leaf to the leaf list (uses separate lock). + if (c.Kind == ClientKind.Leaf) + { + _lmu.EnterWriteLock(); + try { _lleafs.Add(c); } + finally { _lmu.ExitWriteLock(); } + } + + // TODO: session 12 — notify server via c.srv.accConnsUpdate(a). + + return prev; + } + + /// + /// Unregisters a client from this account, updating system and leaf counters. + /// Returns the previous total client count. + /// Mirrors Go (a *Account) removeClient(c *client) int. + /// + private int RemoveClientInternal(ClientConnection c) + { + _mu.EnterWriteLock(); + int prev; + bool wasLeaf = false; + try + { + prev = _clients?.Count ?? 0; + if (_clients == null || !_clients.Remove(c)) + return prev; + + if (IsInternalClientKind(c.Kind)) + { + _sysclients--; + } + else if (c.Kind == ClientKind.Leaf) + { + _nleafs--; + wasLeaf = true; + + // Cluster accounting for hub leaf nodes. + if (c.IsHubLeafNode()) + { + // TODO: session 15 — c.RemoteCluster() for cluster accounting. + } + } + } + finally + { + _mu.ExitWriteLock(); + } + + if (wasLeaf) + { + RemoveLeafNode(c); + } + + // TODO: session 12 — notify server via c.srv.accConnsUpdate(a). + + return prev; + } + + /// + /// Removes a leaf-node client from the ordered leaf list. + /// Uses internally. + /// Mirrors Go (a *Account) removeLeafNode(c *client). + /// + private void RemoveLeafNode(ClientConnection c) + { + _lmu.EnterWriteLock(); + try + { + int idx = _lleafs.IndexOf(c); + if (idx < 0) return; + + int last = _lleafs.Count - 1; + _lleafs[idx] = _lleafs[last]; + _lleafs.RemoveAt(last); + } + finally + { + _lmu.ExitWriteLock(); + } + } + + // ------------------------------------------------------------------------- + // INatsAccount implementation + // ------------------------------------------------------------------------- + + /// + /// Returns true when the account is valid (not expired). + /// Mirrors Go INatsAccount.IsValid. + /// + bool INatsAccount.IsValid => !IsExpired(); + + /// + /// Delegates to . + /// Mirrors Go INatsAccount.MaxTotalConnectionsReached(). + /// + bool INatsAccount.MaxTotalConnectionsReached() => MaxTotalConnectionsReached(); + + /// + /// Delegates to . + /// Mirrors Go INatsAccount.MaxTotalLeafNodesReached(). + /// + bool INatsAccount.MaxTotalLeafNodesReached() => MaxTotalLeafNodesReached(); + + /// + /// Registers a client connection. Returns the previous client count. + /// Mirrors Go INatsAccount.AddClient(c). + /// + int INatsAccount.AddClient(ClientConnection c) => AddClientInternal(c); + + /// + /// Unregisters a client connection. Returns the previous client count. + /// Mirrors Go INatsAccount.RemoveClient(c). + /// + int INatsAccount.RemoveClient(ClientConnection c) => RemoveClientInternal(c); + + // ------------------------------------------------------------------------- + // Static helpers + // ------------------------------------------------------------------------- + + /// + /// Returns true when the user identified by with + /// the given timestamp has been revoked. + /// Also checks the wildcard entry (jwt.All = "*"). + /// Mirrors Go package-level isRevoked(...) bool. + /// + internal static bool IsRevoked( + Dictionary? revocations, + string subject, + long issuedAt) + { + if (revocations == null || revocations.Count == 0) + return false; + + // Check specific key. + if (revocations.TryGetValue(subject, out long ts) && ts >= issuedAt) + return true; + + // Check wildcard revocation ("*" = jwt.All). + if (revocations.TryGetValue("*", out long tsAll) && tsAll >= issuedAt) + return true; + + return false; + } + + /// + /// Returns true if the reply is a tracked reply (ends with "..T"). + /// Mirrors Go package-level isTrackedReply(reply []byte) bool. + /// + internal static bool IsTrackedReply(ReadOnlySpan reply) + { + int lreply = reply.Length - 1; + return lreply > 3 && reply[lreply - 1] == '.' && reply[lreply] == 'T'; + } + + /// + /// Validates a mapping destination subject without creating a full transform. + /// Mirrors Go ValidateMapping(src, dest string) error in sublist.go. + /// Returns null on success; an exception on failure. + /// + internal static Exception? ValidateMapping(string src, string dest) + { + if (string.IsNullOrEmpty(dest)) + return null; + + bool sfwc = false; + foreach (var token in dest.Split('.')) + { + int length = token.Length; + if (length == 0 || sfwc) + return new MappingDestinationException(token, ServerErrors.ErrInvalidMappingDestinationSubject); + + // If it looks like a mapping function, ensure it is a known one. + if (length > 4 && token[0] == '{' && token[1] == '{' && + token[length - 2] == '}' && token[length - 1] == '}') + { + var (tt, _, _, _, terr) = SubjectTransform.IndexPlaceHolders(token); + if (terr != null) return terr; + if (tt == TransformType.BadTransform) + return new MappingDestinationException(token, ServerErrors.ErrUnknownMappingDestinationFunction); + continue; + } + + if (length == 1 && token[0] == '>') + { + sfwc = true; + } + else if (token.IndexOfAny(['\t', '\n', '\f', '\r', ' ']) >= 0) + { + return ServerErrors.ErrInvalidMappingDestinationSubject; + } + } + + // Verify the full transform can be constructed. + var (_, err) = SubjectTransform.New(src, dest); + return err; + } + + /// + /// Returns true if the given is an internal kind + /// (System, JetStream, or Account — not a real user connection). + /// Mirrors Go isInternalClient(kind int) bool. + /// + private static bool IsInternalClientKind(ClientKind kind) => + kind is ClientKind.System or ClientKind.JetStream or ClientKind.Account; + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /// + /// Builds the cumulative-weight destination list from a list of raw-weight + /// entries. If the total weight is less than 100 + /// and the source was not explicitly listed as a destination, a pass-through + /// entry is auto-added for the remainder. + /// Mirrors Go processDestinations(dests []*destination) ([]*destination, error). + /// + private static Exception? ProcessDestinations( + string src, + bool hasWildcard, + HashSet seen, + List dests) + { + byte totalWeight = 0; + foreach (var d in dests) + totalWeight += d.Weight; + + bool haveSrc = seen.Contains(src); + + // Auto-fill the remaining weight with a pass-through to the source. + if (totalWeight != 100 && !haveSrc) + { + string passThroughDest = src; + if (hasWildcard) + passThroughDest = SubjectTransform.TransformTokenize(src); + + var (tr, err) = SubjectTransform.New(src, passThroughDest); + if (err != null) return err; + + byte aw = dests.Count == 0 ? (byte)100 : (byte)(100 - totalWeight); + dests.Add(new Destination { Transform = tr, Weight = aw }); + } + + // Sort ascending by raw weight so the cumulative scan is correct. + dests.Sort((a, b) => a.Weight.CompareTo(b.Weight)); + + // Convert raw weights to cumulative weights. + byte cumulative = 0; + foreach (var d in dests) + { + cumulative += d.Weight; + d.Weight = cumulative; + } + + return null; + } + + /// + /// Tokenises a subject string into an array, using the same split logic + /// as btsep-based tokenisation in the Go source. + /// + private static string[] TokenizeSubjectForMapping(string subject) + { + var parts = new List(); + int start = 0; + for (int i = 0; i < subject.Length; i++) + { + if (subject[i] == '.') + { + parts.Add(subject[start..i]); + start = i + 1; + } + } + parts.Add(subject[start..]); + return [.. parts]; + } + + /// + /// Returns the cached cluster name for cluster-scoped mapping selection. + /// Delegates to the server when available; returns empty string as a stub. + /// Mirrors Go a.srv.cachedClusterName(). + /// TODO: session 09 — wire up Server.CachedClusterName(). + /// + private string GetCachedClusterName() + { + // TODO: session 09 — Server.CachedClusterName(). + return string.Empty; + } + + /// + /// Stops and nulls out a timer. Lock must be held by the caller. + /// Mirrors Go clearTimer(t **time.Timer). + /// + private static void ClearTimerLocked(ref Timer? t) + { + t?.Dispose(); + t = null; + } + + /// + /// Checks whether is authorised to use + /// (either via explicit approval or token requirement). + /// Mirrors Go (a *Account) checkAuth(...) bool. + /// TODO: session 11 — full JWT activation check. + /// + private static bool CheckAuth( + ExportAuth ea, + Account account, + object? imClaim, + string[]? tokens) + { + if (ea.Approved != null && ea.Approved.ContainsKey(account.Name)) + return true; + + if (ea.TokenRequired) + { + // TODO: session 11 — validate activation token in imClaim. + return imClaim != null; + } + + // No approved list and no token required → public export. + if (ea.Approved == null) return true; + + // AccountPosition embedding check. + if (ea.AccountPosition > 0 && tokens != null) + { + int pos = (int)ea.AccountPosition - 1; + if (pos < tokens.Length && tokens[pos] == account.Name) + return true; + } + + return false; + } + + // ------------------------------------------------------------------------- + // Export equality helpers + // ------------------------------------------------------------------------- + + private static bool IsStreamExportEqual(StreamExport? a, StreamExport? b) + { + if (a == null && b == null) return true; + if ((a == null) != (b == null)) return false; + return IsExportAuthEqual(a!, b!); + } + + private static bool IsServiceExportEqual(ServiceExportEntry? a, ServiceExportEntry? b) + { + if (a == null && b == null) return true; + if ((a == null) != (b == null)) return false; + if (!IsExportAuthEqual(a!, b!)) return false; + if ((a!.Account?.Name ?? string.Empty) != (b!.Account?.Name ?? string.Empty)) return false; + if (a.ResponseType != b.ResponseType) return false; + if (a.AllowTrace != b.AllowTrace) return false; + + // Latency comparison. + if ((a.Latency == null) != (b.Latency == null)) return false; + if (a.Latency != null) + { + if (a.Latency.Sampling != b.Latency!.Sampling) return false; + if (a.Latency.Subject != b.Latency.Subject) return false; + } + return true; + } + + private static bool IsExportAuthEqual(ExportAuth a, ExportAuth b) + { + if (a.TokenRequired != b.TokenRequired) return false; + if (a.AccountPosition != b.AccountPosition) return false; + + int aApproved = a.Approved?.Count ?? 0; + int bApproved = b.Approved?.Count ?? 0; + if (aApproved != bApproved) return false; + + if (a.Approved != null) + { + foreach (var (ak, av) in a.Approved) + { + if (!b.Approved!.TryGetValue(ak, out var bv) || + av.Name != bv.Name) + return false; + } + } + + int aRevoked = a.ActivationsRevoked?.Count ?? 0; + int bRevoked = b.ActivationsRevoked?.Count ?? 0; + if (aRevoked != bRevoked) return false; + + if (a.ActivationsRevoked != null) + { + foreach (var (ak, av) in a.ActivationsRevoked) + { + if (!b.ActivationsRevoked!.TryGetValue(ak, out var bv) || av != bv) + return false; + } + } + + return true; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountResolver.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountResolver.cs new file mode 100644 index 0000000..3b4048c --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountResolver.cs @@ -0,0 +1,525 @@ +// Copyright 2018-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/accounts.go in the NATS server Go source. + +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http; + +namespace ZB.MOM.NatsNet.Server; + +// ============================================================================ +// IAccountResolver +// Mirrors Go AccountResolver interface (accounts.go ~line 4035). +// ============================================================================ + +/// +/// Resolves and stores account JWTs by account public key name. +/// Mirrors Go AccountResolver interface. +/// +public interface IAccountResolver +{ + /// + /// Fetches the JWT for the named account. + /// Throws when the account is not found. + /// Mirrors Go AccountResolver.Fetch. + /// + Task FetchAsync(string name, CancellationToken ct = default); + + /// + /// Stores the JWT for the named account. + /// Read-only implementations throw . + /// Mirrors Go AccountResolver.Store. + /// + Task StoreAsync(string name, string jwt, CancellationToken ct = default); + + /// Returns true when no writes are permitted. Mirrors Go IsReadOnly. + bool IsReadOnly(); + + /// + /// Starts any background processing needed by the resolver (system subscriptions, timers, etc.). + /// The parameter accepts an object to avoid a circular assembly + /// reference; implementations should cast it to the concrete server type as needed. + /// Mirrors Go AccountResolver.Start. + /// + void Start(object server); + + /// Returns true when the resolver reacts to JWT update events. Mirrors Go IsTrackingUpdate. + bool IsTrackingUpdate(); + + /// Reloads state from the backing store. Mirrors Go AccountResolver.Reload. + void Reload(); + + /// Releases resources held by the resolver. Mirrors Go AccountResolver.Close. + void Close(); +} + +// ============================================================================ +// ResolverDefaultsOps +// Mirrors Go resolverDefaultsOpsImpl (accounts.go ~line 4046). +// ============================================================================ + +/// +/// Abstract base that provides sensible no-op / read-only defaults for +/// so concrete implementations only need to override what they change. +/// Mirrors Go resolverDefaultsOpsImpl. +/// +public abstract class ResolverDefaultsOps : IAccountResolver +{ + /// + public abstract Task FetchAsync(string name, CancellationToken ct = default); + + /// + /// Default store implementation — always throws because the base defaults to read-only. + /// Mirrors Go resolverDefaultsOpsImpl.Store. + /// + public virtual Task StoreAsync(string name, string jwt, CancellationToken ct = default) + => throw new NotSupportedException("store operation not supported"); + + /// Default: the resolver is read-only. Mirrors Go resolverDefaultsOpsImpl.IsReadOnly. + public virtual bool IsReadOnly() => true; + + /// Default: no-op start. Mirrors Go resolverDefaultsOpsImpl.Start. + public virtual void Start(object server) { } + + /// Default: does not track updates. Mirrors Go resolverDefaultsOpsImpl.IsTrackingUpdate. + public virtual bool IsTrackingUpdate() => false; + + /// Default: no-op reload. Mirrors Go resolverDefaultsOpsImpl.Reload. + public virtual void Reload() { } + + /// Default: no-op close. Mirrors Go resolverDefaultsOpsImpl.Close. + public virtual void Close() { } +} + +// ============================================================================ +// MemoryAccountResolver +// Mirrors Go MemAccResolver (accounts.go ~line 4072). +// ============================================================================ + +/// +/// An in-memory account resolver backed by a . +/// Primarily intended for testing. +/// Mirrors Go MemAccResolver. +/// +public sealed class MemoryAccountResolver : ResolverDefaultsOps +{ + private readonly ConcurrentDictionary _store = new(StringComparer.Ordinal); + + /// In-memory resolver is not read-only. + public override bool IsReadOnly() => false; + + /// + /// Returns the stored JWT for , or throws + /// when the account is unknown. + /// Mirrors Go MemAccResolver.Fetch. + /// + public override Task FetchAsync(string name, CancellationToken ct = default) + { + if (_store.TryGetValue(name, out var jwt)) + { + return Task.FromResult(jwt); + } + + throw new InvalidOperationException($"Account not found: {name}"); + } + + /// + /// Stores for . + /// Mirrors Go MemAccResolver.Store. + /// + public override Task StoreAsync(string name, string jwt, CancellationToken ct = default) + { + _store[name] = jwt; + return Task.CompletedTask; + } +} + +// ============================================================================ +// UrlAccountResolver +// Mirrors Go URLAccResolver (accounts.go ~line 4097). +// ============================================================================ + +/// +/// An HTTP-based account resolver that fetches JWTs by appending the account public key +/// to a configured base URL. +/// Mirrors Go URLAccResolver. +/// +public sealed class UrlAccountResolver : ResolverDefaultsOps +{ + // Mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT. + private static readonly TimeSpan DefaultAccountFetchTimeout = TimeSpan.FromSeconds(2); + + private readonly string _url; + private readonly HttpClient _httpClient; + + /// + /// Creates a new URL resolver for the given . + /// A trailing slash is appended when absent so that account names can be concatenated + /// directly. An is configured with connection-pooling + /// settings that amortise TLS handshakes across requests, mirroring Go's custom + /// http.Transport. + /// Mirrors Go NewURLAccResolver. + /// + public UrlAccountResolver(string url) + { + if (!url.EndsWith('/')) + { + url += "/"; + } + + _url = url; + + // Mirror Go: MaxIdleConns=10, IdleConnTimeout=30s on a custom transport. + var handler = new SocketsHttpHandler + { + MaxConnectionsPerServer = 10, + PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30), + }; + + _httpClient = new HttpClient(handler) + { + Timeout = DefaultAccountFetchTimeout, + }; + } + + /// + /// Issues an HTTP GET to the base URL with the account name appended, and returns + /// the response body as the JWT string. + /// Throws on a non-200 response. + /// Mirrors Go URLAccResolver.Fetch. + /// + public override async Task FetchAsync(string name, CancellationToken ct = default) + { + var requestUrl = _url + name; + HttpResponseMessage response; + + try + { + response = await _httpClient.GetAsync(requestUrl, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + throw new InvalidOperationException($"could not fetch <\"{requestUrl}\">: {ex.Message}", ex); + } + + using (response) + { + if (response.StatusCode != HttpStatusCode.OK) + { + throw new InvalidOperationException( + $"could not fetch <\"{requestUrl}\">: {(int)response.StatusCode} {response.ReasonPhrase}"); + } + + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + } + } +} + +// ============================================================================ +// DirResOption — functional option for DirAccountResolver +// Mirrors Go DirResOption func type (accounts.go ~line 4552). +// ============================================================================ + +/// +/// A functional option that configures a instance. +/// Mirrors Go DirResOption function type. +/// +public delegate void DirResOption(DirAccountResolver resolver); + +/// +/// Factory methods for commonly used values. +/// +public static class DirResOptions +{ + /// + /// Returns an option that overrides the default fetch timeout. + /// must be positive. + /// Mirrors Go FetchTimeout option constructor. + /// + /// + /// Thrown at application time when is not positive. + /// + public static DirResOption FetchTimeout(TimeSpan timeout) + { + if (timeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(timeout), + $"Fetch timeout {timeout} is too small"); + } + + return resolver => resolver.FetchTimeout = timeout; + } +} + +// ============================================================================ +// DirAccountResolver (stub) +// Mirrors Go DirAccResolver (accounts.go ~line 4143). +// Full system-subscription wiring is deferred to session 12. +// ============================================================================ + +/// +/// A directory-backed account resolver that stores JWTs in a +/// and synchronises with peers via NATS system subjects. +/// +/// The Start override that wires up system subscriptions and the periodic sync goroutine +/// is a stub in this session; full implementation requires JetStream and system +/// subscription support (session 12+). +/// +/// Mirrors Go DirAccResolver. +/// +public class DirAccountResolver : ResolverDefaultsOps, IDisposable +{ + // Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s). + private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2); + + // Default sync interval — mirrors Go's fallback of 1 minute. + private static readonly TimeSpan DefaultSyncInterval = TimeSpan.FromMinutes(1); + + /// The underlying directory JWT store. Mirrors Go DirAccResolver.DirJWTStore. + public DirJwtStore Store { get; } + + /// Reference to the running server, set during . Mirrors Go DirAccResolver.Server. + public object? Server { get; protected set; } + + /// How often the resolver sends a sync (pack) request to peers. Mirrors Go DirAccResolver.syncInterval. + public TimeSpan SyncInterval { get; protected set; } + + /// Maximum time to wait for a remote JWT fetch. Mirrors Go DirAccResolver.fetchTimeout. + public TimeSpan FetchTimeout { get; set; } + + /// + /// Creates a new directory account resolver. + /// + /// When is zero it is promoted to (unlimited). + /// When is non-positive it defaults to one minute. + /// + /// Mirrors Go NewDirAccResolver. + /// + /// Directory path for the JWT store. + /// Maximum number of JWTs the store may hold (0 = unlimited). + /// How often to broadcast a sync/pack request to peers. + /// Controls whether deletes are soft- or hard-deleted. + /// Zero or more functional options to further configure this instance. + public DirAccountResolver( + string path, + long limit, + TimeSpan syncInterval, + JwtDeleteType deleteType, + params DirResOption[] opts) + { + if (limit == 0) + { + limit = long.MaxValue; + } + + if (syncInterval <= TimeSpan.Zero) + { + syncInterval = DefaultSyncInterval; + } + + Store = DirJwtStore.NewExpiringDirJwtStore( + path, + shard: false, + create: true, + deleteType, + expireCheck: TimeSpan.Zero, + limit, + evictOnLimit: false, + ttl: TimeSpan.Zero, + changeNotification: null); + + SyncInterval = syncInterval; + FetchTimeout = DefaultFetchTimeout; + + Apply(opts); + } + + // Internal constructor used by CacheDirAccountResolver which supplies its own store. + internal DirAccountResolver( + DirJwtStore store, + TimeSpan syncInterval, + TimeSpan fetchTimeout) + { + Store = store; + SyncInterval = syncInterval; + FetchTimeout = fetchTimeout; + } + + /// + /// Applies a sequence of functional options to this resolver. + /// Mirrors Go DirAccResolver.apply. + /// + protected void Apply(IEnumerable opts) + { + foreach (var opt in opts) + { + opt(this); + } + } + + // ------------------------------------------------------------------------- + // IAccountResolver overrides + // ------------------------------------------------------------------------- + + /// + /// DirAccountResolver is not read-only. + /// Mirrors Go: DirAccResolver does not override IsReadOnly, so it inherits false + /// from the concrete behaviour (store is writable). + /// + public override bool IsReadOnly() => false; + + /// + /// Tracks updates (reacts to JWT change events). + /// Mirrors Go DirAccResolver.IsTrackingUpdate. + /// + public override bool IsTrackingUpdate() => true; + + /// + /// Reloads state from the backing . + /// Mirrors Go DirAccResolver.Reload. + /// + public override void Reload() => Store.Reload(); + + /// + /// Fetches the JWT for from the local . + /// Throws when the account is not found locally. + /// + /// Note: the Go implementation falls back to srv.fetch (a cluster-wide lookup) when + /// the local store misses. That fallback requires system subscriptions and is deferred to + /// session 12. For now this method only consults the local store. + /// + /// Mirrors Go DirAccResolver.Fetch (local path only). + /// + public override Task FetchAsync(string name, CancellationToken ct = default) + { + var theJwt = Store.LoadAcc(name); + if (!string.IsNullOrEmpty(theJwt)) + { + return Task.FromResult(theJwt); + } + + throw new InvalidOperationException($"Account not found: {name}"); + } + + /// + /// Stores under , keeping the newer JWT + /// when a conflicting entry already exists. + /// Mirrors Go DirAccResolver.Store (delegates to saveIfNewer). + /// + public override Task StoreAsync(string name, string jwt, CancellationToken ct = default) + { + // SaveAcc is equivalent to saveIfNewer in the DirJwtStore implementation. + Store.SaveAcc(name, jwt); + return Task.CompletedTask; + } + + /// + /// Starts background system subscriptions and the periodic sync timer. + /// + /// TODO (session 12): wire up system subscriptions for account JWT update/lookup/pack + /// requests, cluster synchronisation, and the periodic pack broadcast goroutine. + /// + /// Mirrors Go DirAccResolver.Start. + /// + public override void Start(object server) + { + Server = server; + // TODO (session 12): set up system subscriptions and periodic sync timer. + } + + /// + /// Stops background processing and closes the . + /// Mirrors Go AccountResolver.Close (no explicit Go override; store is closed + /// by the server shutdown path). + /// + public override void Close() => Store.Close(); + + /// + public void Dispose() => Store.Dispose(); +} + +// ============================================================================ +// CacheDirAccountResolver (stub) +// Mirrors Go CacheDirAccResolver (accounts.go ~line 4594). +// ============================================================================ + +/// +/// A caching variant of that uses a TTL-based expiring +/// store so that fetched JWTs are automatically evicted after . +/// +/// The Start override that wires up system subscriptions is a stub in this session; +/// full implementation requires system subscription support (session 12+). +/// +/// Mirrors Go CacheDirAccResolver. +/// +public sealed class CacheDirAccountResolver : DirAccountResolver +{ + // Default cache limit — mirrors Go's fallback of 1 000 entries. + private const long DefaultCacheLimit = 1_000; + + // Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s). + private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2); + + /// The TTL applied to each cached JWT entry. Mirrors Go CacheDirAccResolver.ttl. + public TimeSpan Ttl { get; } + + /// + /// Creates a new caching directory account resolver. + /// + /// When is zero or negative it defaults to 1 000. + /// + /// Mirrors Go NewCacheDirAccResolver. + /// + /// Directory path for the JWT store. + /// Maximum number of JWTs to cache (0 = 1 000). + /// Time-to-live for each cached JWT. + /// Zero or more functional options to further configure this instance. + public CacheDirAccountResolver( + string path, + long limit, + TimeSpan ttl, + params DirResOption[] opts) + : base( + store: DirJwtStore.NewExpiringDirJwtStore( + path, + shard: false, + create: true, + JwtDeleteType.HardDelete, + expireCheck: TimeSpan.Zero, + limit: limit <= 0 ? DefaultCacheLimit : limit, + evictOnLimit: true, + ttl: ttl, + changeNotification: null), + syncInterval: TimeSpan.Zero, + fetchTimeout: DefaultFetchTimeout) + { + Ttl = ttl; + Apply(opts); + } + + /// + /// Starts background system subscriptions for cached JWT update notifications. + /// + /// TODO (session 12): wire up system subscriptions for account JWT update events + /// (cache variant — does not include pack/list/delete handling). + /// + /// Mirrors Go CacheDirAccResolver.Start. + /// + public override void Start(object server) + { + Server = server; + // TODO (session 12): set up system subscriptions for cache-update notifications. + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountTypes.cs new file mode 100644 index 0000000..fdb9259 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountTypes.cs @@ -0,0 +1,737 @@ +// Copyright 2018-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/accounts.go in the NATS server Go source. + +using System.Text.Json.Serialization; +using ZB.MOM.NatsNet.Server.Auth; +using ZB.MOM.NatsNet.Server.Internal; + +namespace ZB.MOM.NatsNet.Server; + +// ============================================================================ +// AccountLimits — account-based limits +// Mirrors Go `limits` struct in server/accounts.go. +// ============================================================================ + +/// +/// Per-account connection and payload limits. +/// Mirrors Go limits struct in server/accounts.go. +/// +internal sealed class AccountLimits +{ + /// Maximum payload size (-1 = unlimited). Mirrors Go mpay. + public int MaxPayload { get; set; } = -1; + + /// Maximum subscriptions (-1 = unlimited). Mirrors Go msubs. + public int MaxSubscriptions { get; set; } = -1; + + /// Maximum connections (-1 = unlimited). Mirrors Go mconns. + public int MaxConnections { get; set; } = -1; + + /// Maximum leaf nodes (-1 = unlimited). Mirrors Go mleafs. + public int MaxLeafNodes { get; set; } = -1; + + /// When true, bearer tokens are not allowed. Mirrors Go disallowBearer. + public bool DisallowBearer { get; set; } +} + +// ============================================================================ +// SConns — remote server connection/leafnode counters +// Mirrors Go `sconns` struct in server/accounts.go. +// ============================================================================ + +/// +/// Tracks the number of client connections and leaf nodes for a remote server. +/// Mirrors Go sconns struct in server/accounts.go. +/// +internal sealed class SConns +{ + /// Number of client connections from the remote server. Mirrors Go conns. + public int Conns; + + /// Number of leaf nodes from the remote server. Mirrors Go leafs. + public int Leafs; +} + +// ============================================================================ +// ServiceRespType — service response type enum +// Mirrors Go `ServiceRespType` and its iota constants in server/accounts.go. +// ============================================================================ + +/// +/// The response type for an exported service. +/// Mirrors Go ServiceRespType in server/accounts.go. +/// +public enum ServiceRespType : byte +{ + /// A single response is expected. Default. Mirrors Go Singleton. + Singleton = 0, + + /// Multiple responses are streamed. Mirrors Go Streamed. + Streamed = 1, + + /// Responses are sent in chunks. Mirrors Go Chunked. + Chunked = 2, +} + +/// +/// Extension methods for . +/// +public static class ServiceRespTypeExtensions +{ + /// + /// Returns the string representation of the response type. + /// Mirrors Go ServiceRespType.String(). + /// + public static string ToNatsString(this ServiceRespType rt) => rt switch + { + ServiceRespType.Singleton => "Singleton", + ServiceRespType.Streamed => "Streamed", + ServiceRespType.Chunked => "Chunked", + _ => "Unknown ServiceResType", + }; +} + +// ============================================================================ +// ExportAuth — export authorization configuration +// Mirrors Go `exportAuth` struct in server/accounts.go. +// ============================================================================ + +/// +/// Holds configured approvals or a flag indicating that an auth token is +/// required for import. +/// Mirrors Go exportAuth struct in server/accounts.go. +/// +internal class ExportAuth +{ + /// When true, an auth token is required to import this export. Mirrors Go tokenReq. + public bool TokenRequired { get; set; } + + /// + /// Position in the subject token where the account name appears (for + /// public exports that embed the importing account name). + /// Mirrors Go accountPos. + /// + public uint AccountPosition { get; set; } + + /// + /// Accounts explicitly approved to import this export. + /// Key is the account name. Mirrors Go approved. + /// + public Dictionary? Approved { get; set; } + + /// + /// Accounts whose activations have been revoked. + /// Key is the account name, value is the revocation timestamp (Unix ns). + /// Mirrors Go actsRevoked. + /// + public Dictionary? ActivationsRevoked { get; set; } +} + +// ============================================================================ +// StreamExport — exported stream descriptor +// Mirrors Go `streamExport` struct in server/accounts.go. +// ============================================================================ + +/// +/// Describes a stream exported by an account. +/// Mirrors Go streamExport struct in server/accounts.go. +/// +internal sealed class StreamExport : ExportAuth +{ + // No additional fields beyond ExportAuth for now. + // Full implementation in session 11 (accounts.go). +} + +// ============================================================================ +// InternalServiceLatency — service latency tracking configuration +// Mirrors Go `serviceLatency` struct in server/accounts.go. +// ============================================================================ + +/// +/// Configuration for service latency tracking on an exported service. +/// Mirrors Go serviceLatency struct in server/accounts.go. +/// +internal sealed class InternalServiceLatency +{ + /// + /// Sampling percentage (1–100), or 0 to indicate triggered by header. + /// Mirrors Go sampling int8. + /// + public int Sampling { get; set; } + + /// Subject to publish latency metrics to. Mirrors Go subject. + public string Subject { get; set; } = string.Empty; +} + +// ============================================================================ +// ServiceExportEntry — exported service descriptor +// Mirrors Go `serviceExport` struct in server/accounts.go. +// ============================================================================ + +/// +/// Describes a service exported by an account with additional configuration +/// for response type, latency tracking, and timers. +/// Mirrors Go serviceExport struct in server/accounts.go. +/// +internal sealed class ServiceExportEntry : ExportAuth +{ + /// Account that owns this export. Mirrors Go acc. + public Account? Account { get; set; } + + /// Response type (Singleton, Streamed, Chunked). Mirrors Go respType. + public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton; + + /// Latency tracking configuration, or null if disabled. Mirrors Go latency. + public InternalServiceLatency? Latency { get; set; } + + /// + /// Timer used to collect response-latency measurements. + /// Mirrors Go rtmr *time.Timer. + /// + public Timer? ResponseTimer { get; set; } + + /// + /// Threshold duration for service responses. + /// Mirrors Go respThresh time.Duration. + /// + public TimeSpan ResponseThreshold { get; set; } + + /// + /// When true, tracing is allowed past the account boundary for this export. + /// Mirrors Go atrc (allow_trace). + /// + public bool AllowTrace { get; set; } +} + +// ============================================================================ +// ExportMap — tracks exported streams and services for an account +// Mirrors Go `exportMap` struct in server/accounts.go. +// ============================================================================ + +/// +/// Tracks all stream exports, service exports, and response mappings for an account. +/// Mirrors Go exportMap struct in server/accounts.go. +/// +internal sealed class ExportMap +{ + /// + /// Exported streams keyed by subject pattern. + /// Mirrors Go streams map[string]*streamExport. + /// + public Dictionary? Streams { get; set; } + + /// + /// Exported services keyed by subject pattern. + /// Mirrors Go services map[string]*serviceExport. + /// + public Dictionary? Services { get; set; } + + /// + /// In-flight response service imports keyed by reply subject. + /// Mirrors Go responses map[string]*serviceImport. + /// + public Dictionary? Responses { get; set; } +} + +// ============================================================================ +// ImportMap — tracks imported streams and services for an account +// Mirrors Go `importMap` struct in server/accounts.go. +// ============================================================================ + +/// +/// Tracks all stream imports, service imports, and reverse-response maps. +/// Mirrors Go importMap struct in server/accounts.go. +/// +internal sealed class ImportMap +{ + /// + /// Imported streams (ordered list). + /// Mirrors Go streams []*streamImport. + /// + public List? Streams { get; set; } + + /// + /// Imported services keyed by subject pattern; each key may have + /// multiple import entries (e.g. fan-out imports). + /// Mirrors Go services map[string][]*serviceImport. + /// + public Dictionary>? Services { get; set; } + + /// + /// Reverse-response map used to clean up singleton service imports. + /// Mirrors Go rrMap map[string][]*serviceRespEntry. + /// + public Dictionary>? ReverseResponseMap { get; set; } +} + +// ============================================================================ +// StreamImportEntry — an imported stream mapping +// Mirrors Go `streamImport` struct in server/accounts.go. +// ============================================================================ + +/// +/// An imported stream from another account, with optional subject remapping. +/// Mirrors Go streamImport struct in server/accounts.go. +/// +internal sealed class StreamImportEntry +{ + /// Account providing the stream. Mirrors Go acc. + public Account? Account { get; set; } + + /// Source subject on the exporting account. Mirrors Go from. + public string From { get; set; } = string.Empty; + + /// Destination subject on the importing account. Mirrors Go to. + public string To { get; set; } = string.Empty; + + /// + /// Subject transform applied to the source subject. + /// Mirrors Go tr *subjectTransform. + /// Stubbed as until the transform + /// engine is wired in. + /// + public ISubjectTransformer? Transform { get; set; } + + /// + /// Reverse transform for reply subjects. + /// Mirrors Go rtr *subjectTransform. + /// + public ISubjectTransformer? ReverseTransform { get; set; } + + /// + /// JWT import claim that authorized this import. + /// Mirrors Go claim *jwt.Import. + /// Stubbed as object? until JWT integration is complete (session 11). + /// + public object? Claim { get; set; } + + /// + /// When true, use the published subject instead of . + /// Mirrors Go usePub. + /// + public bool UsePublishedSubject { get; set; } + + /// Whether this import is considered invalid. Mirrors Go invalid. + public bool Invalid { get; set; } + + /// + /// When true, tracing is allowed past the account boundary. + /// Mirrors Go atrc (allow_trace). + /// + public bool AllowTrace { get; set; } +} + +// ============================================================================ +// ServiceImportEntry — an imported service mapping +// Mirrors Go `serviceImport` struct in server/accounts.go. +// ============================================================================ + +/// +/// An imported service from another account, with response routing and +/// latency tracking state. +/// Mirrors Go serviceImport struct in server/accounts.go. +/// +internal sealed class ServiceImportEntry +{ + /// Account providing the service. Mirrors Go acc. + public Account? Account { get; set; } + + /// + /// JWT import claim that authorized this import. + /// Mirrors Go claim *jwt.Import. + /// Stubbed as object? until JWT integration is complete (session 11). + /// + public object? Claim { get; set; } + + /// Parent service export entry. Mirrors Go se *serviceExport. + public ServiceExportEntry? ServiceExport { get; set; } + + /// + /// Subscription ID byte slice for cleanup. + /// Mirrors Go sid []byte. + /// + public byte[]? SubscriptionId { get; set; } + + /// Source subject on the importing account. Mirrors Go from. + public string From { get; set; } = string.Empty; + + /// Destination subject on the exporting account. Mirrors Go to. + public string To { get; set; } = string.Empty; + + /// + /// Subject transform applied when routing requests. + /// Mirrors Go tr *subjectTransform. + /// Stubbed as until transform engine is wired in. + /// + public ISubjectTransformer? Transform { get; set; } + + /// + /// Timestamp (Unix nanoseconds) when the import request was created. + /// Used for latency tracking. Mirrors Go ts int64. + /// + public long Timestamp { get; set; } + + /// Response type for this service import. Mirrors Go rt ServiceRespType. + public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton; + + /// Latency tracking configuration. Mirrors Go latency *serviceLatency. + public InternalServiceLatency? Latency { get; set; } + + /// + /// First-leg latency measurement (requestor side). + /// Mirrors Go m1 *ServiceLatency. + /// + public ServiceLatency? M1 { get; set; } + + /// + /// Client connection that sent the original request. + /// Mirrors Go rc *client. + /// + public ClientConnection? RequestingClient { get; set; } + + /// + /// When true, use the published subject instead of . + /// Mirrors Go usePub. + /// + public bool UsePublishedSubject { get; set; } + + /// + /// When true, this import entry represents a pending response rather + /// than an originating request. + /// Mirrors Go response. + /// + public bool IsResponse { get; set; } + + /// Whether this import is considered invalid. Mirrors Go invalid. + public bool Invalid { get; set; } + + /// + /// When true, the requestor's is shared with + /// the responder. Mirrors Go share. + /// + public bool Share { get; set; } + + /// Whether latency tracking is active. Mirrors Go tracking. + public bool Tracking { get; set; } + + /// Whether a response was delivered to the requestor. Mirrors Go didDeliver. + public bool DidDeliver { get; set; } + + /// + /// When true, tracing is allowed past the account boundary (inherited + /// from the service export). Mirrors Go atrc. + /// + public bool AllowTrace { get; set; } + + /// + /// Headers from the original request, used when latency is triggered by + /// a header. Mirrors Go trackingHdr http.Header. + /// + public Dictionary? TrackingHeader { get; set; } +} + +// ============================================================================ +// ServiceRespEntry — reverse-response map entry +// Mirrors Go `serviceRespEntry` struct in server/accounts.go. +// ============================================================================ + +/// +/// Records a service import mapping for reverse-response-map cleanup. +/// Mirrors Go serviceRespEntry struct in server/accounts.go. +/// +internal sealed class ServiceRespEntry +{ + /// Account that owns the service import. Mirrors Go acc. + public Account? Account { get; set; } + + /// + /// The mapped subscription subject used for the response. + /// Mirrors Go msub. + /// + public string MappedSubject { get; set; } = string.Empty; +} + +// ============================================================================ +// MapDest — public API for weighted subject mappings +// Mirrors Go `MapDest` struct in server/accounts.go. +// ============================================================================ + +/// +/// Describes a weighted mapping destination for published subjects. +/// Mirrors Go MapDest struct in server/accounts.go. +/// +public sealed class MapDest +{ + [JsonPropertyName("subject")] + public string Subject { get; set; } = string.Empty; + + [JsonPropertyName("weight")] + public byte Weight { get; set; } + + [JsonPropertyName("cluster")] + public string Cluster { get; set; } = string.Empty; + + /// + /// Creates a new with the given subject and weight. + /// Mirrors Go NewMapDest. + /// + public static MapDest New(string subject, byte weight) => + new() { Subject = subject, Weight = weight }; +} + +// ============================================================================ +// Destination — internal weighted mapped destination +// Mirrors Go `destination` struct in server/accounts.go. +// ============================================================================ + +/// +/// Internal representation of a weighted mapped destination, holding a +/// transform and a weight. +/// Mirrors Go destination struct in server/accounts.go. +/// +internal sealed class Destination +{ + /// + /// Transform that converts the source subject to the destination subject. + /// Mirrors Go tr *subjectTransform. + /// + public ISubjectTransformer? Transform { get; set; } + + /// + /// Relative weight (0–100). Mirrors Go weight uint8. + /// + public byte Weight { get; set; } +} + +// ============================================================================ +// SubjectMapping — internal subject mapping entry +// Mirrors Go `mapping` struct in server/accounts.go. +// Renamed from `mapping` to avoid collision with the C# keyword context. +// ============================================================================ + +/// +/// An internal entry describing how a source subject is remapped to one or +/// more weighted destinations, optionally scoped to specific clusters. +/// Mirrors Go mapping struct in server/accounts.go. +/// +internal sealed class SubjectMapping +{ + /// Source subject pattern. Mirrors Go src. + public string Source { get; set; } = string.Empty; + + /// + /// Whether the source contains wildcards. + /// Mirrors Go wc. + /// + public bool HasWildcard { get; set; } + + /// + /// Weighted destinations with no cluster scope. + /// Mirrors Go dests []*destination. + /// + public List Destinations { get; set; } = []; + + /// + /// Per-cluster weighted destinations. + /// Key is the cluster name. Mirrors Go cdests map[string][]*destination. + /// + public Dictionary>? ClusterDestinations { get; set; } +} + +// ============================================================================ +// TypedEvent — base for server advisory events +// Mirrors Go `TypedEvent` struct in server/events.go. +// Included here because ServiceLatency embeds it. +// ============================================================================ + +/// +/// Base fields for a NATS typed event or advisory. +/// Mirrors Go TypedEvent struct in server/events.go. +/// +public class TypedEvent +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("timestamp")] + public DateTime Time { get; set; } +} + +// ============================================================================ +// ServiceLatency — public latency measurement event +// Mirrors Go `ServiceLatency` struct in server/accounts.go. +// ============================================================================ + +/// +/// The JSON message published to a latency-tracking subject when a service +/// request completes. Includes requestor and responder timing breakdowns. +/// Mirrors Go ServiceLatency struct in server/accounts.go. +/// +public sealed class ServiceLatency : TypedEvent +{ + [JsonPropertyName("status")] + public int Status { get; set; } + + [JsonPropertyName("description")] + public string Error { get; set; } = string.Empty; + + [JsonPropertyName("requestor")] + public ClientInfo? Requestor { get; set; } + + [JsonPropertyName("responder")] + public ClientInfo? Responder { get; set; } + + /// + /// Headers from the original request that triggered latency measurement. + /// Mirrors Go RequestHeader http.Header. + /// + [JsonPropertyName("header")] + public Dictionary? RequestHeader { get; set; } + + [JsonPropertyName("start")] + public DateTime RequestStart { get; set; } + + /// Mirrors Go ServiceLatency time.Duration (nanoseconds). + [JsonPropertyName("service")] + public TimeSpan ServiceLatencyDuration { get; set; } + + /// Mirrors Go SystemLatency time.Duration (nanoseconds). + [JsonPropertyName("system")] + public TimeSpan SystemLatency { get; set; } + + /// Mirrors Go TotalLatency time.Duration (nanoseconds). + [JsonPropertyName("total")] + public TimeSpan TotalLatency { get; set; } + + /// + /// Returns the sum of requestor RTT, responder RTT, and system latency. + /// Mirrors Go ServiceLatency.NATSTotalTime(). + /// + public TimeSpan NATSTotalTime() + { + var requestorRtt = Requestor?.Rtt ?? TimeSpan.Zero; + var responderRtt = Responder?.Rtt ?? TimeSpan.Zero; + return requestorRtt + responderRtt + SystemLatency; + } +} + +// ============================================================================ +// RemoteLatency — cross-server latency transport message +// Mirrors Go `remoteLatency` struct in server/accounts.go. +// ============================================================================ + +/// +/// Used to transport a responder-side latency measurement to the +/// requestor's server so the two halves can be merged. +/// Mirrors Go remoteLatency struct in server/accounts.go. +/// +internal sealed class RemoteLatency +{ + [JsonPropertyName("account")] + public string Account { get; set; } = string.Empty; + + [JsonPropertyName("req_id")] + public string RequestId { get; set; } = string.Empty; + + [JsonPropertyName("m2")] + public ServiceLatency M2 { get; set; } = new(); + + /// + /// Private: response latency threshold used when deciding whether to + /// send the remote measurement. + /// Mirrors Go respThresh time.Duration. + /// + public TimeSpan ResponseThreshold { get; set; } +} + +// ============================================================================ +// RsiReason — reason for removing a response service import +// Mirrors Go `rsiReason` and its iota constants in server/accounts.go. +// ============================================================================ + +/// +/// The reason a response service import entry is being removed. +/// Mirrors Go rsiReason and its iota constants in server/accounts.go. +/// +internal enum RsiReason +{ + /// Normal completion. Mirrors Go rsiOk. + Ok = 0, + + /// Response was never delivered. Mirrors Go rsiNoDelivery. + NoDelivery = 1, + + /// Response timed out. Mirrors Go rsiTimeout. + Timeout = 2, +} + +// ============================================================================ +// Account-level constants +// Mirrors the const blocks in server/accounts.go. +// ============================================================================ + +/// +/// Constants related to account route-pool indexing and search depth. +/// +internal static class AccountConstants +{ + /// + /// Sentinel value indicating the account has a dedicated route connection. + /// Mirrors Go accDedicatedRoute = -1. + /// + public const int DedicatedRoute = -1; + + /// + /// Sentinel value indicating the account is in the process of transitioning + /// to a dedicated route. + /// Mirrors Go accTransitioningToDedicatedRoute = -2. + /// + public const int TransitioningToDedicatedRoute = -2; + + /// + /// Maximum depth for account cycle detection when following import chains. + /// Mirrors Go MaxAccountCycleSearchDepth = 1024. + /// + public const int MaxCycleSearchDepth = 1024; +} + +/// +/// Well-known header names and event type identifiers used by the account +/// service-latency and client-info subsystems. +/// +public static class AccountEventConstants +{ + /// + /// Header name used to pass client metadata into a service request. + /// Mirrors Go ClientInfoHdr = "Nats-Request-Info". + /// + public const string ClientInfoHeader = "Nats-Request-Info"; + + /// + /// The default threshold (in nanoseconds, as a ) below + /// which a subscription-limit report is suppressed. + /// Mirrors Go defaultMaxSubLimitReportThreshold = int64(2 * time.Second). + /// + public static readonly TimeSpan DefaultMaxSubLimitReportThreshold = TimeSpan.FromSeconds(2); + + /// + /// NATS event type identifier for messages. + /// Mirrors Go ServiceLatencyType = "io.nats.server.metric.v1.service_latency". + /// + public const string ServiceLatencyType = "io.nats.server.metric.v1.service_latency"; +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs new file mode 100644 index 0000000..980d099 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs @@ -0,0 +1,1373 @@ +// Copyright 2012-2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/dirstore.go in the NATS server Go source. + +using System.Security.Cryptography; +using System.Text; + +namespace ZB.MOM.NatsNet.Server; + +/// +/// Called when a JWT in the store has changed (created or updated) or been deleted. +/// The publicKey argument is the NKey public key for the affected JWT. +/// Mirrors Go JWTChanged delegate type. +/// +public delegate void JwtChanged(string publicKey); + +/// +/// Controls how deleted JWT files are handled by . +/// Mirrors Go deleteType constants. +/// +public enum JwtDeleteType +{ + /// Deletion is not permitted. + NoDelete = 0, + + /// The JWT file is renamed with a .deleted suffix rather than removed. + RenameDeleted = 1, + + /// The JWT file is permanently removed from disk. + HardDelete = 2, +} + +/// +/// A directory-backed JWT store that keeps one JWT per public key as a .jwt file. +/// Optionally shards files into two-character subdirectories, tracks expiration via a +/// background timer, and enforces a per-store file-count limit with optional LRU eviction. +/// Mirrors Go DirJWTStore. +/// +public sealed class DirJwtStore : IDisposable +{ + // --------------------------------------------------------------------------- + // Constants + // --------------------------------------------------------------------------- + + private const string FileExtension = ".jwt"; + private const string DeletedSuffix = ".deleted"; + + // --------------------------------------------------------------------------- + // State + // --------------------------------------------------------------------------- + + private readonly object _lock = new(); + private readonly string _directory; + private readonly bool _shard; + private readonly bool _readonly; + private readonly JwtDeleteType _deleteType; + private ExpirationTracker? _expiration; + private readonly JwtChanged? _changed; + private readonly JwtChanged? _deleted; + + // --------------------------------------------------------------------------- + // Constructors (private — use factory methods) + // --------------------------------------------------------------------------- + + private DirJwtStore( + string directory, + bool shard, + bool readOnly, + JwtDeleteType deleteType, + JwtChanged? changed, + JwtChanged? deleted) + { + _directory = directory; + _shard = shard; + _readonly = readOnly; + _deleteType = deleteType; + _changed = changed; + _deleted = deleted; + } + + // --------------------------------------------------------------------------- + // Factory methods + // --------------------------------------------------------------------------- + + /// + /// Creates a read-only directory JWT store. + /// The directory must already exist; it is never created. + /// Mirrors Go NewImmutableDirJWTStore. + /// + /// Path to the directory containing JWT files. + /// When true, files are stored in two-character sharded subdirectories. + public static DirJwtStore NewImmutableDirJwtStore(string dirPath, bool shard) + { + var store = NewDirJwtStore(dirPath, shard, create: false); + // Override: force readonly flag (NewDirJwtStore defaults to non-readonly) + return new DirJwtStore(store._directory, store._shard, readOnly: true, + JwtDeleteType.NoDelete, changed: null, deleted: null); + } + + /// + /// Creates a directory JWT store without expiration tracking. + /// Mirrors Go NewDirJWTStore. + /// + /// Path to the directory. + /// When true, files are stored in sharded subdirectories. + /// When true, the directory is created if it does not exist. + public static DirJwtStore NewDirJwtStore(string dirPath, bool shard, bool create) + { + var fullPath = NewDir(dirPath, create); + return new DirJwtStore(fullPath, shard, readOnly: false, + JwtDeleteType.NoDelete, changed: null, deleted: null); + } + + /// + /// Creates a directory JWT store with expiration tracking, file-count limiting, and + /// optional LRU eviction. A background timer fires every + /// and removes JWT files whose expiration time has passed. + /// Mirrors Go NewExpiringDirJWTStore. + /// + /// Path to the directory. + /// When true, files are stored in sharded subdirectories. + /// When true, the directory is created if it does not exist. + /// Controls how expired/deleted files are removed. + /// + /// Interval at which the background timer checks for expired JWTs. + /// If zero or negative, defaults to ttl/2 or one minute, whichever is smaller. + /// + /// + /// Maximum number of JWTs tracked simultaneously. + /// Pass to disable the limit. + /// + /// + /// When true, the least-recently-used JWT is evicted when the limit is reached; + /// when false, an error is returned instead. + /// + /// + /// When non-zero, overrides the per-JWT expiration with a fixed TTL from the last + /// access. Pass to make JWTs indefinitely valid. + /// + /// Callback invoked when a JWT is added or updated. + public static DirJwtStore NewExpiringDirJwtStore( + string dirPath, + bool shard, + bool create, + JwtDeleteType deleteType, + TimeSpan expireCheck, + long limit, + bool evictOnLimit, + TimeSpan ttl, + JwtChanged? changeNotification) + { + var fullPath = NewDir(dirPath, create); + + // Mirror Go: derive a sensible default check interval when caller passes ≤0. + if (expireCheck <= TimeSpan.Zero) + { + if (ttl != TimeSpan.Zero) + { + expireCheck = ttl / 2; + } + if (expireCheck == TimeSpan.Zero || expireCheck > TimeSpan.FromMinutes(1)) + { + expireCheck = TimeSpan.FromMinutes(1); + } + } + + if (limit <= 0) + { + limit = long.MaxValue; + } + + var store = new DirJwtStore(fullPath, shard, readOnly: false, + deleteType, changed: changeNotification, deleted: null); + + store.StartExpiring(expireCheck, limit, evictOnLimit, ttl); + + // Pre-index all existing JWT files on disk. + lock (store._lock) + { + foreach (var path in EnumerateJwtFiles(fullPath)) + { + try + { + var theJwt = File.ReadAllBytes(path); + var hash = SHA256.HashData(theJwt); + var file = Path.GetFileNameWithoutExtension(path); + store._expiration!.Track(file, hash, Encoding.UTF8.GetString(theJwt)); + } + catch + { + // Ignore files that cannot be read during startup — mirrors Go behaviour. + } + } + } + + return store; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /// Returns true when the store was opened in read-only mode. + public bool IsReadOnly() => _readonly; + + /// + /// Loads the JWT for the given account public key. + /// Mirrors Go DirJWTStore.LoadAcc. + /// + public string LoadAcc(string publicKey) => Load(publicKey); + + /// + /// Saves the JWT for the given account public key. + /// Mirrors Go DirJWTStore.SaveAcc. + /// + public void SaveAcc(string publicKey, string theJwt) => Save(publicKey, theJwt); + + /// + /// Loads the JWT activation token identified by . + /// Mirrors Go DirJWTStore.LoadAct. + /// + public string LoadAct(string hash) => Load(hash); + + /// + /// Saves the JWT activation token identified by . + /// Mirrors Go DirJWTStore.SaveAct. + /// + public void SaveAct(string hash, string theJwt) => Save(hash, theJwt); + + /// + /// Stops the background expiration timer and releases resources. + /// Mirrors Go DirJWTStore.Close. + /// + public void Close() + { + lock (_lock) + { + _expiration?.Close(); + _expiration = null; + } + } + + /// + public void Dispose() => Close(); + + /// + /// Packs up to JWTs from the store into a newline-delimited + /// string where each line is publicKey|jwtData. + /// Pass a negative value for to include all JWTs. + /// Mirrors Go DirJWTStore.Pack. + /// + public string Pack(int maxJwts) + { + var pack = new List(maxJwts > 0 ? maxJwts : 16); + lock (_lock) + { + foreach (var path in EnumerateJwtFiles(_directory)) + { + if (maxJwts >= 0 && pack.Count == maxJwts) + { + break; + } + + var pubKey = Path.GetFileNameWithoutExtension(path); + + // When expiration is active, only include indexed (tracked) entries. + if (_expiration != null && !_expiration.IsTracked(pubKey)) + { + continue; + } + + byte[] jwtBytes; + try + { + jwtBytes = File.ReadAllBytes(path); + } + catch + { + continue; + } + + if (jwtBytes.Length == 0) + { + continue; + } + + // When expiration is active, skip already-expired JWTs. + if (_expiration != null && IsJwtExpired(jwtBytes)) + { + continue; + } + + pack.Add($"{pubKey}|{Encoding.UTF8.GetString(jwtBytes)}"); + } + } + return string.Join("\n", pack); + } + + /// + /// Walks all JWT files in batches of , invoking + /// with each partial pack message. + /// Mirrors Go DirJWTStore.PackWalk. + /// + /// + /// Thrown when is zero or negative, or + /// is null. + /// + public void PackWalk(int maxJwts, Action callback) + { + if (maxJwts <= 0 || callback == null) + { + throw new ArgumentException("bad arguments to PackWalk"); + } + + string directory; + ExpirationTracker? exp; + lock (_lock) + { + directory = _directory; + exp = _expiration; + } + + var batch = new List(maxJwts); + + foreach (var path in EnumerateJwtFiles(directory)) + { + var pubKey = Path.GetFileNameWithoutExtension(path); + + lock (_lock) + { + if (exp != null && !exp.IsTracked(pubKey)) + { + continue; + } + } + + byte[] jwtBytes; + try + { + jwtBytes = File.ReadAllBytes(path); + } + catch + { + continue; + } + + if (jwtBytes.Length == 0) + { + continue; + } + + if (exp != null && IsJwtExpired(jwtBytes)) + { + continue; + } + + batch.Add($"{pubKey}|{Encoding.UTF8.GetString(jwtBytes)}"); + + if (batch.Count == maxJwts) + { + callback(string.Join("\n", batch)); + batch.Clear(); + } + } + + if (batch.Count > 0) + { + callback(string.Join("\n", batch)); + } + } + + /// + /// Merges the JWTs from a pack string (as produced by ) into the store. + /// Each line must be of the form publicKey|jwtData. + /// Mirrors Go DirJWTStore.Merge. + /// + /// + /// Thrown when any line is malformed or the public key is not a valid account key. + /// + public void Merge(string pack) + { + var lines = pack.Split('\n'); + foreach (var line in lines) + { + if (string.IsNullOrEmpty(line)) + { + continue; + } + + var pipeIndex = line.IndexOf('|'); + if (pipeIndex < 0) + { + throw new InvalidOperationException( + $"Line in package didn't contain 2 entries: \"{line}\""); + } + + var pubKey = line[..pipeIndex]; + var jwtData = line[(pipeIndex + 1)..]; + + // Validate that this is a plausible public account key (non-empty, reasonable length). + if (string.IsNullOrWhiteSpace(pubKey)) + { + throw new InvalidOperationException( + "Key to merge is not a valid public account key"); + } + + SaveIfNewer(pubKey, jwtData); + } + } + + /// + /// Reloads all JWT files from disk into the expiration tracker, invoking the + /// change callback for any JWT whose content differs from the in-memory record. + /// No-ops when expiration tracking is disabled or the store is read-only. + /// Mirrors Go DirJWTStore.Reload. + /// + public void Reload() + { + ExpirationTracker? exp; + Dictionary? oldHashes; + JwtChanged? changed; + bool isCache; + + lock (_lock) + { + exp = _expiration; + if (exp == null || _readonly) + { + return; + } + + changed = _changed; + isCache = exp.EvictOnLimit; + oldHashes = exp.SnapshotHashes(); + + // Clear existing tracking state — mirrors Go clearing heap/idx/lru/hash. + exp.Reset(); + } + + foreach (var path in EnumerateJwtFiles(_directory)) + { + try + { + var theJwt = File.ReadAllBytes(path); + var hash = SHA256.HashData(theJwt); + var file = Path.GetFileNameWithoutExtension(path); + + // Determine whether the callback should fire. + var notify = isCache; // for caches, always notify (entry may have been evicted) + if (oldHashes != null && oldHashes.TryGetValue(file, out var oldHash)) + { + notify = !hash.AsSpan().SequenceEqual(oldHash); + } + + lock (_lock) + { + exp.Track(file, hash, Encoding.UTF8.GetString(theJwt)); + } + + if (notify) + { + changed?.Invoke(file); + } + } + catch + { + // Ignore unreadable files — mirrors Go behaviour. + } + } + } + + /// + /// Returns the XOR of all individual JWT SHA-256 hashes currently tracked by the + /// expiration index. Returns a zero-filled array when expiration tracking is disabled. + /// Mirrors Go DirJWTStore.Hash. + /// + public byte[] Hash() + { + lock (_lock) + { + return _expiration?.GetHash() ?? new byte[SHA256.HashSizeInBytes]; + } + } + + // --------------------------------------------------------------------------- + // Private helpers — path + // --------------------------------------------------------------------------- + + /// + /// Returns the filesystem path for the given public key, optionally under a two-character + /// shard subdirectory derived from the last two characters of the key. + /// Mirrors Go DirJWTStore.pathForKey. + /// + private string PathForKey(string publicKey) + { + if (publicKey.Length < 2) + { + return string.Empty; + } + + var fileName = publicKey + FileExtension; + + if (_shard) + { + // Go uses the last two characters of the key as the shard directory name. + var shard = publicKey[^2..]; + return Path.Combine(_directory, shard, fileName); + } + + return Path.Combine(_directory, fileName); + } + + // --------------------------------------------------------------------------- + // Private helpers — load / write / save + // --------------------------------------------------------------------------- + + /// + /// Loads the JWT for from disk. + /// Mirrors Go DirJWTStore.load. + /// + private string Load(string publicKey) + { + lock (_lock) + { + var path = PathForKey(publicKey); + if (string.IsNullOrEmpty(path)) + { + throw new InvalidOperationException("Invalid public key"); + } + + string data; + try + { + data = File.ReadAllText(path); + } + catch (FileNotFoundException) + { + throw new FileNotFoundException($"JWT not found for key: {publicKey}", path); + } + + _expiration?.UpdateTrack(publicKey); + return data; + } + } + + /// + /// Writes to and updates the expiration + /// tracker. Returns true when a new file was created or the content changed; false when the + /// content is identical to what is already tracked. + /// Assumes the lock is already held. + /// Mirrors Go DirJWTStore.write. + /// + private bool Write(string path, string publicKey, string theJwt) + { + if (string.IsNullOrEmpty(theJwt)) + { + throw new InvalidOperationException("Invalid JWT"); + } + + byte[]? newHash = null; + + if (_expiration != null) + { + newHash = SHA256.HashData(Encoding.UTF8.GetBytes(theJwt)); + + if (_expiration.IsTracked(publicKey)) + { + _expiration.UpdateTrack(publicKey); + + // If content is identical, skip the write — mirrors Go. + var existingHash = _expiration.GetItemHash(publicKey); + if (existingHash != null && newHash.AsSpan().SequenceEqual(existingHash)) + { + return false; + } + } + else if (_expiration.Count >= _expiration.Limit) + { + if (!_expiration.EvictOnLimit) + { + throw new InvalidOperationException("JWT store is full"); + } + + // Evict the least-recently-used entry. + var lruKey = _expiration.LruFront(); + if (lruKey != null) + { + var lruPath = PathForKey(lruKey); + try + { + File.Delete(lruPath); + } + catch + { + // Best-effort removal. + } + _expiration.UnTrack(lruKey); + } + } + } + + File.WriteAllText(path, theJwt); + + if (_expiration != null && newHash != null) + { + _expiration.Track(publicKey, newHash, theJwt); + } + + return true; + } + + /// + /// Deletes the JWT for according to . + /// Mirrors Go DirJWTStore.delete. + /// + private void Delete(string publicKey) + { + if (_readonly) + { + throw new InvalidOperationException("Store is read-only"); + } + if (_deleteType == JwtDeleteType.NoDelete) + { + throw new InvalidOperationException("Store is not set up for delete"); + } + + lock (_lock) + { + var name = PathForKey(publicKey); + + if (_deleteType == JwtDeleteType.RenameDeleted) + { + try + { + File.Move(name, name + DeletedSuffix, overwrite: true); + } + catch (FileNotFoundException) + { + return; // already gone — mirrors Go os.IsNotExist behaviour + } + } + else + { + try + { + File.Delete(name); + } + catch (FileNotFoundException) + { + return; + } + } + + _expiration?.UnTrack(publicKey); + _deleted?.Invoke(publicKey); + } + } + + /// + /// Saves for , creating shard + /// directories as needed, and fires the change callback if the content changed. + /// Mirrors Go DirJWTStore.save. + /// + private void Save(string publicKey, string theJwt) + { + if (_readonly) + { + throw new InvalidOperationException("Store is read-only"); + } + + bool changed; + JwtChanged? cb; + + lock (_lock) + { + var path = PathForKey(publicKey); + if (string.IsNullOrEmpty(path)) + { + throw new InvalidOperationException("Invalid public key"); + } + + // Ensure the shard subdirectory exists. + var dirPath = Path.GetDirectoryName(path)!; + if (!Directory.Exists(dirPath)) + { + Directory.CreateDirectory(dirPath); + } + + changed = Write(path, publicKey, theJwt); + cb = _changed; + } + + if (changed) + { + cb?.Invoke(publicKey); + } + } + + /// + /// Saves only if it is newer than what is already on disk + /// (determined by comparing the iat field decoded from the JWT payload). + /// Mirrors Go DirJWTStore.saveIfNewer. + /// + private void SaveIfNewer(string publicKey, string theJwt) + { + if (_readonly) + { + throw new InvalidOperationException("Store is read-only"); + } + + var path = PathForKey(publicKey); + if (string.IsNullOrEmpty(path)) + { + throw new InvalidOperationException("Invalid public key"); + } + + // Ensure the shard subdirectory exists. + var dirPath = Path.GetDirectoryName(path)!; + if (!Directory.Exists(dirPath)) + { + Directory.CreateDirectory(dirPath); + } + + if (File.Exists(path)) + { + // Decode both JWTs to compare issued-at timestamps. + // JWT is three base64url parts separated by '.': header.payload.signature + long newIssuedAt = DecodeJwtIssuedAt(theJwt); + long existingIssuedAt = 0; + string? newId = DecodeJwtId(theJwt); + string? existingId = null; + + try + { + var existingData = File.ReadAllText(path); + existingIssuedAt = DecodeJwtIssuedAt(existingData); + existingId = DecodeJwtId(existingData); + + // Skip if same ID (already up to date). + if (newId != null && existingId != null && newId == existingId) + { + return; + } + + // Skip if existing is newer. + if (existingIssuedAt > newIssuedAt) + { + return; + } + } + catch + { + // Cannot decode existing JWT — proceed with overwrite (mirrors Go: "skip if it + // can't be decoded" means the error path falls through to the write). + } + } + + bool changed; + JwtChanged? cb; + + lock (_lock) + { + cb = _changed; + changed = Write(path, publicKey, theJwt); + } + + if (changed) + { + cb?.Invoke(publicKey); + } + } + + // --------------------------------------------------------------------------- + // Private helpers — expiration management + // --------------------------------------------------------------------------- + + private void StartExpiring(TimeSpan reCheck, long limit, bool evictOnLimit, TimeSpan ttl) + { + lock (_lock) + { + var tracker = new ExpirationTracker(limit, evictOnLimit, ttl); + _expiration = tracker; + + // Background timer — mirrors Go goroutine + time.Ticker. + var timer = new Timer(_ => + { + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * TimeSpan.TicksPerMillisecond; + + while (true) + { + string? expiredKey; + lock (_lock) + { + expiredKey = tracker.PeekExpired(now); + if (expiredKey == null) + { + break; + } + + var expiredPath = PathForKey(expiredKey); + try + { + File.Delete(expiredPath); + } + catch + { + // If delete fails, stop trying — mirrors Go: "if err := os.Remove ...; err == nil". + break; + } + + tracker.PopExpired(); + tracker.UnTrack(expiredKey); + } + } + }, null, reCheck, reCheck); + + tracker.SetTimer(timer); + } + } + + // --------------------------------------------------------------------------- + // Private static helpers + // --------------------------------------------------------------------------- + + /// + /// Validates that exists and is a directory, optionally + /// creating it when is true. + /// Returns the absolute path. + /// Mirrors Go newDir. + /// + private static string NewDir(string dirPath, bool create) + { + if (string.IsNullOrEmpty(dirPath)) + { + throw new ArgumentException("Path is not specified", nameof(dirPath)); + } + + if (Directory.Exists(dirPath)) + { + return Path.GetFullPath(dirPath); + } + + if (!create) + { + throw new DirectoryNotFoundException( + $"The path [{dirPath}] doesn't exist"); + } + + Directory.CreateDirectory(dirPath); + + if (!Directory.Exists(dirPath)) + { + throw new DirectoryNotFoundException( + $"Failed to create directory [{dirPath}]"); + } + + return Path.GetFullPath(dirPath); + } + + /// + /// Enumerates all .jwt files beneath recursively, + /// skipping files whose names end with .deleted. + /// + private static IEnumerable EnumerateJwtFiles(string rootDirectory) + { + return Directory + .EnumerateFiles(rootDirectory, "*" + FileExtension, SearchOption.AllDirectories) + .Where(p => !p.EndsWith(DeletedSuffix, StringComparison.Ordinal)); + } + + /// + /// Returns true when the JWT encoded in has a non-zero + /// expiration claim that is in the past. + /// + private static bool IsJwtExpired(byte[] jwtBytes) + { + try + { + var jwtString = Encoding.UTF8.GetString(jwtBytes); + var exp = DecodeJwtExp(jwtString); + if (exp > 0) + { + return exp < DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + } + } + catch + { + // Malformed JWT — treat as not expired. + } + return false; + } + + /// + /// Decodes the exp (expiration) claim from the JWT payload. + /// Returns 0 when not present or on decode error. + /// JWTs are three base64url segments separated by '.': header.payload.signature. + /// + private static long DecodeJwtExp(string jwt) + { + return DecodeLongClaim(jwt, "\"exp\":"); + } + + /// + /// Decodes the iat (issued-at) claim from the JWT payload. + /// Returns 0 when not present or on decode error. + /// + private static long DecodeJwtIssuedAt(string jwt) + { + return DecodeLongClaim(jwt, "\"iat\":"); + } + + /// + /// Decodes the jti (JWT ID) claim from the JWT payload. + /// Returns null when not present or on decode error. + /// + private static string? DecodeJwtId(string jwt) + { + return DecodeStringClaim(jwt, "\"jti\":\""); + } + + private static long DecodeLongClaim(string jwt, string claimKey) + { + try + { + var parts = jwt.Split('.'); + if (parts.Length < 2) + { + return 0; + } + + var payload = Base64UrlDecode(parts[1]); + var json = Encoding.UTF8.GetString(payload); + + var idx = json.IndexOf(claimKey, StringComparison.Ordinal); + if (idx < 0) + { + return 0; + } + + var valueStart = idx + claimKey.Length; + var valueEnd = valueStart; + while (valueEnd < json.Length && + (char.IsDigit(json[valueEnd]) || json[valueEnd] == '-')) + { + valueEnd++; + } + + if (long.TryParse(json.AsSpan(valueStart, valueEnd - valueStart), out var result)) + { + return result; + } + } + catch + { + // Ignore decode errors. + } + return 0; + } + + private static string? DecodeStringClaim(string jwt, string claimKeyPrefix) + { + try + { + var parts = jwt.Split('.'); + if (parts.Length < 2) + { + return null; + } + + var payload = Base64UrlDecode(parts[1]); + var json = Encoding.UTF8.GetString(payload); + + var idx = json.IndexOf(claimKeyPrefix, StringComparison.Ordinal); + if (idx < 0) + { + return null; + } + + var valueStart = idx + claimKeyPrefix.Length; + var valueEnd = json.IndexOf('"', valueStart); + if (valueEnd < 0) + { + return null; + } + + return json[valueStart..valueEnd]; + } + catch + { + return null; + } + } + + /// Decodes a base64url-encoded string (no padding required). + private static byte[] Base64UrlDecode(string input) + { + var padded = input + .Replace('-', '+') + .Replace('_', '/'); + + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + + return Convert.FromBase64String(padded); + } + + /// + /// XOR-assigns each byte of into . + /// Mirrors Go xorAssign. + /// + private static void XorAssign(byte[] lVal, byte[] rVal) + { + for (var i = 0; i < rVal.Length && i < lVal.Length; i++) + { + lVal[i] ^= rVal[i]; + } + } +} + +// --------------------------------------------------------------------------- +// Expiration tracker +// --------------------------------------------------------------------------- + +/// +/// Tracks JWT expiration using a min-heap ordered by expiration timestamp and an LRU +/// doubly-linked list for eviction. Maintains a rolling XOR hash of all tracked JWTs. +/// Mirrors Go expirationTracker. +/// +internal sealed class ExpirationTracker +{ + // Min-heap ordered by expiration (Unix nanoseconds stored as ticks for TimeSpan compatibility). + private readonly PriorityQueue _heap; + + // Index from publicKey to JwtItem for O(1) lookup and hash tracking. + private readonly Dictionary _idx; + + // LRU list — front is least-recently used, back is most-recently used. + private readonly LinkedList _lru; + + // XOR of all tracked item hashes — matches Go's rolling hash approach. + private byte[] _hash; + + private Timer? _timer; + + internal long Limit { get; } + internal bool EvictOnLimit { get; } + internal TimeSpan Ttl { get; } + + internal int Count => _idx.Count; + + internal ExpirationTracker(long limit, bool evictOnLimit, TimeSpan ttl) + { + Limit = limit; + EvictOnLimit = evictOnLimit; + Ttl = ttl; + _heap = new PriorityQueue(); + _idx = new Dictionary(StringComparer.Ordinal); + _lru = new LinkedList(); + _hash = new byte[SHA256.HashSizeInBytes]; + } + + internal void SetTimer(Timer timer) => _timer = timer; + + /// + /// Adds or updates tracking for . + /// When an entry already exists its expiration and hash are updated. + /// Mirrors Go expirationTracker.track. + /// + internal void Track(string publicKey, byte[] hash, string theJwt) + { + long exp; + if (Ttl != TimeSpan.Zero) + { + exp = Ttl == TimeSpan.MaxValue + ? long.MaxValue + : (DateTimeOffset.UtcNow + Ttl).UtcTicks; + } + else + { + // Decode the JWT expiration field. + exp = DecodeJwtExpTicks(theJwt); + if (exp == 0) + { + exp = long.MaxValue; // indefinite + } + } + + if (_idx.TryGetValue(publicKey, out var existing)) + { + // Remove old hash contribution from rolling XOR. + XorAssign(_hash, existing.Hash); + + // Update in-place. + existing.Expiration = exp; + existing.Hash = hash; + + // Re-enqueue with updated priority (PriorityQueue does not support update; + // use a version counter approach — mark old entry stale, enqueue fresh). + existing.Version++; + _heap.Enqueue(existing, exp); + } + else + { + var item = new JwtItem(publicKey, exp, hash); + _idx[publicKey] = item; + _heap.Enqueue(item, exp); + _lru.AddLast(publicKey); + } + + XorAssign(_hash, hash); + } + + /// + /// Updates the LRU position and optionally the expiration for . + /// Mirrors Go expirationTracker.updateTrack. + /// + internal void UpdateTrack(string publicKey) + { + if (!_idx.TryGetValue(publicKey, out var item)) + { + return; + } + + if (Ttl != TimeSpan.Zero) + { + var newExp = Ttl == TimeSpan.MaxValue + ? long.MaxValue + : (DateTimeOffset.UtcNow + Ttl).UtcTicks; + + item.Expiration = newExp; + item.Version++; + _heap.Enqueue(item, newExp); + } + + if (EvictOnLimit) + { + // Move to back of LRU list (most recently used). + var node = _lru.Find(publicKey); + if (node != null) + { + _lru.Remove(node); + _lru.AddLast(publicKey); + } + } + } + + /// + /// Removes tracking for and updates the rolling hash. + /// Mirrors Go expirationTracker.unTrack. + /// + internal void UnTrack(string publicKey) + { + if (!_idx.TryGetValue(publicKey, out var item)) + { + return; + } + + XorAssign(_hash, item.Hash); + _idx.Remove(publicKey); + + var node = _lru.Find(publicKey); + if (node != null) + { + _lru.Remove(node); + } + + // Invalidate heap entry so it is ignored when it surfaces during PeekExpired/PopExpired. + item.Removed = true; + } + + /// Returns true when is currently tracked. + internal bool IsTracked(string publicKey) => _idx.ContainsKey(publicKey); + + /// Returns the hash for , or null when not tracked. + internal byte[]? GetItemHash(string publicKey) + => _idx.TryGetValue(publicKey, out var item) ? item.Hash : null; + + /// Returns the public key at the front of the LRU list (least recently used). + internal string? LruFront() => _lru.First?.Value; + + /// + /// Peeks at the min-heap root and returns the public key if its expiration is at or + /// before . Returns null when no entries are expired. + /// + internal string? PeekExpired(long nowTicks) + { + // Drain stale (removed or version-superseded) heap entries. + DrainStale(); + + if (_heap.Count == 0) + { + return null; + } + + _heap.TryPeek(out var item, out var priority); + if (item == null || priority > nowTicks) + { + return null; + } + + return item.Removed || !_idx.ContainsKey(item.PublicKey) ? null : item.PublicKey; + } + + /// + /// Removes the min-heap root (the entry returned by the most recent ). + /// + internal void PopExpired() + { + DrainStale(); + if (_heap.Count > 0) + { + _heap.Dequeue(); + } + } + + /// Returns a copy of the rolling XOR hash. + internal byte[] GetHash() + { + var copy = new byte[_hash.Length]; + _hash.CopyTo(copy, 0); + return copy; + } + + /// + /// Snapshots the current item hashes keyed by public key, for use by + /// . + /// + internal Dictionary SnapshotHashes() + { + var snap = new Dictionary(_idx.Count, StringComparer.Ordinal); + foreach (var (key, item) in _idx) + { + var copy = new byte[item.Hash.Length]; + item.Hash.CopyTo(copy, 0); + snap[key] = copy; + } + return snap; + } + + /// + /// Resets all tracking state in preparation for a full reload. + /// Mirrors Go clearing of heap, idx, lru, and hash. + /// + internal void Reset() + { + _heap.Clear(); + _idx.Clear(); + _lru.Clear(); + Array.Clear(_hash); + } + + /// Stops the background expiration timer. + internal void Close() + { + _timer?.Dispose(); + _timer = null; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private void DrainStale() + { + while (_heap.Count > 0 && + _heap.TryPeek(out var top, out _) && + (top.Removed || !_idx.TryGetValue(top.PublicKey, out var current) || !ReferenceEquals(current, top))) + { + _heap.Dequeue(); + } + } + + private static void XorAssign(byte[] lVal, byte[] rVal) + { + for (var i = 0; i < rVal.Length && i < lVal.Length; i++) + { + lVal[i] ^= rVal[i]; + } + } + + private static long DecodeJwtExpTicks(string jwt) + { + try + { + var parts = jwt.Split('.'); + if (parts.Length < 2) + { + return 0; + } + + var padded = parts[1].Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + + var payload = Convert.FromBase64String(padded); + var json = Encoding.UTF8.GetString(payload); + + const string expKey = "\"exp\":"; + var idx = json.IndexOf(expKey, StringComparison.Ordinal); + if (idx < 0) + { + return 0; + } + + var valueStart = idx + expKey.Length; + var valueEnd = valueStart; + while (valueEnd < json.Length && + (char.IsDigit(json[valueEnd]) || json[valueEnd] == '-')) + { + valueEnd++; + } + + if (long.TryParse(json.AsSpan(valueStart, valueEnd - valueStart), out var expSeconds)) + { + // Convert Unix seconds to UTC ticks for comparison with DateTimeOffset.UtcTicks. + return DateTimeOffset.FromUnixTimeSeconds(expSeconds).UtcTicks; + } + } + catch + { + // Ignore. + } + return 0; + } +} + +// --------------------------------------------------------------------------- +// Heap item +// --------------------------------------------------------------------------- + +/// +/// Represents a single JWT entry in the expiration tracker's min-heap. +/// Mirrors Go jwtItem. +/// +internal sealed class JwtItem +{ + internal string PublicKey { get; } + internal long Expiration { get; set; } + internal byte[] Hash { get; set; } + + /// + /// Monotonically increasing counter; when a heap entry's version differs from the + /// canonical item in the index it is considered stale and is skipped. + /// + internal int Version { get; set; } + + /// Marked true by to invalidate heap entries. + internal bool Removed { get; set; } + + internal JwtItem(string publicKey, long expiration, byte[] hash) + { + PublicKey = publicKey; + Expiration = expiration; + Hash = hash; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs index 74a97fd..d1ddb19 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs @@ -168,45 +168,5 @@ public class RoutePermissions public SubjectPermission? Export { get; set; } } -/// -/// Stub for Account type. Full implementation in session 11. -/// Mirrors Go Account struct. -/// -public class Account : INatsAccount -{ - public string Name { get; set; } = string.Empty; - - // Fields required by session 09 account management (NatsServer.Accounts.cs). - // Full implementation in session 11. - public string Issuer { get; set; } = string.Empty; - internal string ClaimJwt { get; set; } = string.Empty; - internal int RoutePoolIdx { get; set; } - internal bool Incomplete { get; set; } - internal DateTime Updated { get; set; } - internal ZB.MOM.NatsNet.Server.Internal.DataStructures.SubscriptionIndex? Sublist { get; set; } - internal object? Server { get; set; } // INatsServer — avoids circular reference - - // INatsAccount — stub implementations until session 11 (accounts.go). - bool INatsAccount.IsValid => true; - bool INatsAccount.MaxTotalConnectionsReached() => false; - bool INatsAccount.MaxTotalLeafNodesReached() => false; - int INatsAccount.AddClient(ClientConnection c) => 0; - int INatsAccount.RemoveClient(ClientConnection c) => 0; - - /// Returns true if this account's JWT has expired. Stub — full impl in session 11. - public bool IsExpired() => false; - - /// - /// Returns the total number of subscriptions across all clients in this account. - /// Stub — full implementation in session 11. - /// Mirrors Go Account.TotalSubs(). - /// - public int TotalSubs() => 0; - - /// - /// Notifies leaf nodes of a subscription change. - /// Stub — full implementation in session 15. - /// Mirrors Go Account.updateLeafNodes(). - /// - internal void UpdateLeafNodes(object sub, int delta) { } -} +// Account stub removed — full implementation is in Accounts/Account.cs +// in the ZB.MOM.NatsNet.Server namespace. diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs index d4108a2..06c25a1 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs @@ -273,6 +273,13 @@ public sealed class ClientInfo public bool Disconnect { get; set; } public string[]? Cluster { get; set; } public bool Service { get; set; } + + /// + /// Round-trip time to the client. + /// Mirrors Go RTT time.Duration in events.go. + /// Added here to support . + /// + public TimeSpan Rtt { get; set; } } // ============================================================================ diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Accounts.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Accounts.cs index 0a56ae4..5646a1f 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Accounts.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Accounts.cs @@ -671,8 +671,15 @@ public sealed partial class NatsServer { if (_accResolver == null) return (string.Empty, ServerErrors.ErrNoAccountResolver); - var (jwt, err) = _accResolver.Fetch(name); - return (jwt, err); + try + { + var jwt = _accResolver.FetchAsync(name).GetAwaiter().GetResult(); + return (jwt, null); + } + catch (Exception ex) + { + return (string.Empty, ex); + } } /// diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs index c12a926..e2ef659 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Init.cs @@ -1044,7 +1044,7 @@ public sealed partial class NatsServer { // Validate JWT format (stub — session 06 has JWT decoder). // jwt.DecodeAccountClaims(v) — skip here, checked again in CheckResolvePreloads. - ar.Store(k, v); + ar.StoreAsync(k, v).GetAwaiter().GetResult(); } } return null; diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs index 713b43c..5f0ae03 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs @@ -461,17 +461,4 @@ public interface IClientAuthentication string RemoteAddress(); } -/// -/// Account resolver interface for dynamic account loading. -/// Mirrors AccountResolver in accounts.go. -/// -public interface IAccountResolver -{ - (string jwt, Exception? err) Fetch(string name); - Exception? Store(string name, string jwt); - bool IsReadOnly(); - Exception? Start(object server); - bool IsTrackingUpdate(); - Exception? Reload(); - void Close(); -} +// IAccountResolver is defined in Accounts/AccountResolver.cs. diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs index 8afd0d5..5835819 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs @@ -109,7 +109,7 @@ public sealed partial class ServerOptions public string SystemAccount { get; set; } = string.Empty; public bool NoSystemAccount { get; set; } /// Parsed account objects from config. Mirrors Go opts.Accounts. - public List Accounts { get; set; } = []; + public List Accounts { get; set; } = []; public AuthCalloutOpts? AuthCallout { get; set; } public bool AlwaysEnableNonce { get; set; } public List? Users { get; set; } diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/AccountTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/AccountTests.cs new file mode 100644 index 0000000..e088c16 --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Accounts/AccountTests.cs @@ -0,0 +1,478 @@ +// Copyright 2018-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/accounts_test.go and server/dirstore_test.go in the NATS server Go source. + +using Shouldly; +using Xunit; + +namespace ZB.MOM.NatsNet.Server.Tests; + +[Collection("AccountTests")] +public sealed class AccountTests +{ + // ========================================================================= + // Account Basic Tests + // ========================================================================= + + // Test 1 + [Fact] + public void NewAccount_SetsNameAndUnlimitedLimits() + { + var acc = Account.NewAccount("foo"); + + acc.Name.ShouldBe("foo"); + acc.MaxConnections.ShouldBe(-1); + acc.MaxLeafNodes.ShouldBe(-1); + } + + // Test 2 + [Fact] + public void ToString_ReturnsName() + { + var acc = Account.NewAccount("myaccount"); + + acc.ToString().ShouldBe(acc.Name); + } + + // Test 3 + [Fact] + public void IsExpired_InitiallyFalse() + { + var acc = Account.NewAccount("foo"); + + acc.IsExpired().ShouldBeFalse(); + } + + // Test 4 + [Fact] + public void IsClaimAccount_NoJwt_ReturnsFalse() + { + var acc = Account.NewAccount("foo"); + // ClaimJwt defaults to empty string + acc.IsClaimAccount().ShouldBeFalse(); + } + + // Test 5 + [Fact] + public void NumConnections_Initial_IsZero() + { + var acc = Account.NewAccount("foo"); + + acc.NumConnections().ShouldBe(0); + } + + // Test 6 + [Fact] + public void GetName_ReturnsName() + { + var acc = Account.NewAccount("thread-safe-name"); + + acc.GetName().ShouldBe("thread-safe-name"); + } + + // ========================================================================= + // Subject Mapping Tests + // ========================================================================= + + // Test 7 + [Fact] + public void AddMapping_ValidSubject_Succeeds() + { + var acc = Account.NewAccount("foo"); + + var err = acc.AddMapping("foo", "bar"); + + err.ShouldBeNull(); + } + + // Test 8 + [Fact] + public void AddMapping_InvalidSubject_ReturnsError() + { + var acc = Account.NewAccount("foo"); + + var err = acc.AddMapping("foo..bar", "x"); + + err.ShouldNotBeNull(); + } + + // Test 9 + [Fact] + public void RemoveMapping_ExistingMapping_ReturnsTrue() + { + var acc = Account.NewAccount("foo"); + acc.AddMapping("foo", "bar").ShouldBeNull(); + + var removed = acc.RemoveMapping("foo"); + + removed.ShouldBeTrue(); + } + + // Test 10 + [Fact] + public void RemoveMapping_NonExistentMapping_ReturnsFalse() + { + var acc = Account.NewAccount("foo"); + + var removed = acc.RemoveMapping("nonexistent"); + + removed.ShouldBeFalse(); + } + + // Test 11 + [Fact] + public void HasMappings_AfterAdd_ReturnsTrue() + { + var acc = Account.NewAccount("foo"); + acc.AddMapping("foo", "bar").ShouldBeNull(); + + acc.HasMappings().ShouldBeTrue(); + } + + // Test 12 + [Fact] + public void HasMappings_AfterRemove_ReturnsFalse() + { + var acc = Account.NewAccount("foo"); + acc.AddMapping("foo", "bar").ShouldBeNull(); + acc.RemoveMapping("foo"); + + acc.HasMappings().ShouldBeFalse(); + } + + // Test 13 + [Fact] + public void SelectMappedSubject_NoMapping_ReturnsFalse() + { + var acc = Account.NewAccount("foo"); + + var (dest, mapped) = acc.SelectMappedSubject("foo"); + + mapped.ShouldBeFalse(); + dest.ShouldBe("foo"); + } + + // Test 14 + [Fact] + public void SelectMappedSubject_SimpleMapping_ReturnsMappedDest() + { + var acc = Account.NewAccount("foo"); + acc.AddMapping("foo", "bar").ShouldBeNull(); + + var (dest, mapped) = acc.SelectMappedSubject("foo"); + + mapped.ShouldBeTrue(); + dest.ShouldBe("bar"); + } + + // Test 15 + [Fact] + public void AddWeightedMappings_DuplicateDest_ReturnsError() + { + var acc = Account.NewAccount("foo"); + + var err = acc.AddWeightedMappings("src", + MapDest.New("dest1", 50), + MapDest.New("dest1", 50)); // duplicate subject + + err.ShouldNotBeNull(); + } + + // Test 16 + [Fact] + public void AddWeightedMappings_WeightOver100_ReturnsError() + { + var acc = Account.NewAccount("foo"); + + var err = acc.AddWeightedMappings("src", + MapDest.New("dest1", 101)); // weight exceeds 100 + + err.ShouldNotBeNull(); + } + + // Test 17 + [Fact] + public void AddWeightedMappings_TotalWeightOver100_ReturnsError() + { + var acc = Account.NewAccount("foo"); + + var err = acc.AddWeightedMappings("src", + MapDest.New("dest1", 80), + MapDest.New("dest2", 80)); // total = 160 + + err.ShouldNotBeNull(); + } + + // ========================================================================= + // Connection Counting Tests + // ========================================================================= + + // Test 18 + [Fact] + public void NumLeafNodes_Initial_IsZero() + { + var acc = Account.NewAccount("foo"); + + acc.NumLeafNodes().ShouldBe(0); + } + + // Test 19 + [Fact] + public void MaxTotalConnectionsReached_UnlimitedAccount_ReturnsFalse() + { + var acc = Account.NewAccount("foo"); + // MaxConnections is -1 (unlimited) by default + + acc.MaxTotalConnectionsReached().ShouldBeFalse(); + } + + // Test 20 + [Fact] + public void MaxTotalLeafNodesReached_UnlimitedAccount_ReturnsFalse() + { + var acc = Account.NewAccount("foo"); + // MaxLeafNodes is -1 (unlimited) by default + + acc.MaxTotalLeafNodesReached().ShouldBeFalse(); + } + + // ========================================================================= + // Export Service Tests + // ========================================================================= + + // Test 21 + [Fact] + public void IsExportService_NoExports_ReturnsFalse() + { + var acc = Account.NewAccount("foo"); + + acc.IsExportService("my.service").ShouldBeFalse(); + } + + // Test 22 + [Fact] + public void IsExportServiceTracking_NoExports_ReturnsFalse() + { + var acc = Account.NewAccount("foo"); + + acc.IsExportServiceTracking("my.service").ShouldBeFalse(); + } +} + +// ========================================================================= +// DirJwtStore Tests +// ========================================================================= + +[Collection("AccountTests")] +public sealed class DirJwtStoreTests : IDisposable +{ + private readonly List _tempDirs = []; + + private string MakeTempDir() + { + var dir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(dir); + _tempDirs.Add(dir); + return dir; + } + + public void Dispose() + { + foreach (var dir in _tempDirs) + { + try { Directory.Delete(dir, true); } catch { /* best effort */ } + } + } + + // Test 23 + [Fact] + public void DirJwtStore_WriteAndRead_Succeeds() + { + var dir = MakeTempDir(); + using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false); + + const string key = "AAAAAAAAAA"; // minimum 2-char key + const string jwt = "header.payload.signature"; + + store.SaveAcc(key, jwt); + var loaded = store.LoadAcc(key); + + loaded.ShouldBe(jwt); + } + + // Test 24 + [Fact] + public void DirJwtStore_ShardedWriteAndRead_Succeeds() + { + var dir = MakeTempDir(); + using var store = DirJwtStore.NewDirJwtStore(dir, shard: true, create: false); + + var keys = new[] { "ACCTKEY001", "ACCTKEY002", "ACCTKEY003" }; + foreach (var k in keys) + { + store.SaveAcc(k, $"jwt.for.{k}"); + } + + foreach (var k in keys) + { + store.LoadAcc(k).ShouldBe($"jwt.for.{k}"); + } + } + + // Test 25 + [Fact] + public void DirJwtStore_EmptyKey_ReturnsError() + { + var dir = MakeTempDir(); + using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false); + + // LoadAcc with key shorter than 2 chars should throw + Should.Throw(() => store.LoadAcc("")); + + // SaveAcc with key shorter than 2 chars should throw + Should.Throw(() => store.SaveAcc("", "some.jwt")); + } + + // Test 26 + [Fact] + public void DirJwtStore_MissingKey_ReturnsError() + { + var dir = MakeTempDir(); + using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false); + + Should.Throw(() => store.LoadAcc("NONEXISTENT_KEY")); + } + + // Test 27 + [Fact] + public void DirJwtStore_Pack_ContainsSavedJwts() + { + var dir = MakeTempDir(); + using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false); + + store.SaveAcc("ACCTKEYAAA", "jwt1.data.sig"); + store.SaveAcc("ACCTKEYBBB", "jwt2.data.sig"); + + var packed = store.Pack(-1); + + packed.ShouldContain("ACCTKEYAAA|jwt1.data.sig"); + packed.ShouldContain("ACCTKEYBBB|jwt2.data.sig"); + } + + // Test 28 + [Fact] + public void DirJwtStore_Merge_AddsNewEntries() + { + var dir = MakeTempDir(); + using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false); + + // Pack format: key|jwt lines separated by newline + var packData = "ACCTKEYMERGE|merged.jwt.value"; + store.Merge(packData); + + var loaded = store.LoadAcc("ACCTKEYMERGE"); + loaded.ShouldBe("merged.jwt.value"); + } + + // Test 29 + [Fact] + public void DirJwtStore_ReadOnly_Prevents_Write() + { + var dir = MakeTempDir(); + // Write a file first so the dir is valid + var writeable = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false); + writeable.SaveAcc("ACCTKEYRO", "original.jwt"); + writeable.Dispose(); + + // Open as immutable + using var readOnly = DirJwtStore.NewImmutableDirJwtStore(dir, shard: false); + + readOnly.IsReadOnly().ShouldBeTrue(); + Should.Throw(() => readOnly.SaveAcc("ACCTKEYRO", "new.jwt")); + } +} + +// ========================================================================= +// MemoryAccountResolver Tests +// ========================================================================= + +[Collection("AccountTests")] +public sealed class MemoryAccountResolverTests +{ + // Test 30 + [Fact] + public async Task MemoryAccountResolver_StoreAndFetch_Roundtrip() + { + var resolver = new MemoryAccountResolver(); + const string key = "MYACCOUNTKEY"; + const string jwt = "header.payload.sig"; + + await resolver.StoreAsync(key, jwt); + var fetched = await resolver.FetchAsync(key); + + fetched.ShouldBe(jwt); + } + + // Test 31 + [Fact] + public async Task MemoryAccountResolver_Fetch_MissingKey_Throws() + { + var resolver = new MemoryAccountResolver(); + + await Should.ThrowAsync( + () => resolver.FetchAsync("DOESNOTEXIST")); + } + + // Test 32 + [Fact] + public void MemoryAccountResolver_IsReadOnly_ReturnsFalse() + { + var resolver = new MemoryAccountResolver(); + + resolver.IsReadOnly().ShouldBeFalse(); + } +} + +// ========================================================================= +// UrlAccountResolver Tests +// ========================================================================= + +[Collection("AccountTests")] +public sealed class UrlAccountResolverTests +{ + // Test 33 + [Fact] + public void UrlAccountResolver_NormalizesTrailingSlash() + { + // Two constructors: one with slash, one without. + // We verify construction doesn't throw and the resolver is usable. + // (We cannot inspect _url directly since it's private, but we can + // infer correctness via IsReadOnly and lack of constructor exception.) + var resolverNoSlash = new UrlAccountResolver("http://localhost:9090"); + var resolverWithSlash = new UrlAccountResolver("http://localhost:9090/"); + + // Both should construct without error and have the same observable behaviour. + resolverNoSlash.IsReadOnly().ShouldBeTrue(); + resolverWithSlash.IsReadOnly().ShouldBeTrue(); + } + + // Test 34 + [Fact] + public void UrlAccountResolver_IsReadOnly_ReturnsTrue() + { + var resolver = new UrlAccountResolver("http://localhost:9090"); + + resolver.IsReadOnly().ShouldBeTrue(); + } +} diff --git a/porting.db b/porting.db index c19893cd8d1841b99ee3979901c4e320b4d874b1..685e0db9dfd825148ddd6eba7b2e31948fc6ebed 100644 GIT binary patch delta 21080 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 S)8e5Sdol$7)a6Nc3;zqp>LVTi diff --git a/reports/current.md b/reports/current.md index 96d6544..b456a35 100644 --- a/reports/current.md +++ b/reports/current.md @@ -1,6 +1,6 @@ # NATS .NET Porting Status Report -Generated: 2026-02-26 20:08:24 UTC +Generated: 2026-02-26 20:37:09 UTC ## Modules (12 total) @@ -13,18 +13,18 @@ Generated: 2026-02-26 20:08:24 UTC | Status | Count | |--------|-------| -| complete | 841 | +| complete | 1075 | | n_a | 82 | -| not_started | 2657 | +| not_started | 2423 | | stub | 93 | ## Unit Tests (3257 total) | Status | Count | |--------|-------| -| complete | 278 | +| complete | 319 | | n_a | 181 | -| not_started | 2574 | +| not_started | 2533 | | stub | 224 | ## Library Mappings (36 total) @@ -36,4 +36,4 @@ Generated: 2026-02-26 20:08:24 UTC ## Overall Progress -**1393/6942 items complete (20.1%)** +**1668/6942 items complete (24.0%)** diff --git a/reports/report_06779a1.md b/reports/report_06779a1.md new file mode 100644 index 0000000..b456a35 --- /dev/null +++ b/reports/report_06779a1.md @@ -0,0 +1,39 @@ +# NATS .NET Porting Status Report + +Generated: 2026-02-26 20:37:09 UTC + +## Modules (12 total) + +| Status | Count | +|--------|-------| +| complete | 11 | +| not_started | 1 | + +## Features (3673 total) + +| Status | Count | +|--------|-------| +| complete | 1075 | +| n_a | 82 | +| not_started | 2423 | +| stub | 93 | + +## Unit Tests (3257 total) + +| Status | Count | +|--------|-------| +| complete | 319 | +| n_a | 181 | +| not_started | 2533 | +| stub | 224 | + +## Library Mappings (36 total) + +| Status | Count | +|--------|-------| +| mapped | 36 | + + +## Overall Progress + +**1668/6942 items complete (24.0%)**