10 Commits

Author SHA1 Message Date
Joseph Doherty
11b387e442 feat: port session 08 — Client Connection & PROXY Protocol
- ClientConnection: full connection lifecycle, string/identity helpers,
  SplitSubjectQueue, KindString, MsgParts, SetHeader, message header
  manipulation (GenHeader, RemoveHeader, SliceHeader, GetHeader)
- ClientTypes: ClientConnectionType, ClientProtocol, ClientFlags,
  ReadCacheFlags, ClosedState, PmrFlags, DenyType, ClientOptions,
  ClientInfo, NbPool, RouteTarget, ClientKindHelpers
- NatsMessageHeaders: complete header utility class (GenHeader,
  RemoveHeaderIfPrefixPresent, RemoveHeaderIfPresent, SliceHeader,
  GetHeader, SetHeader, GetHeaderKeyIndex)
- ProxyProtocol: PROXY protocol v1/v2 parser (ReadV1Header,
  ParseV2Header, ReadProxyProtoHeader sync entry point)
- ServerErrors: add ErrAuthorization sentinel
- Tests: 32 standalone unit tests (proxy protocol: IDs 159-168,
  171-178, 180-181; client: IDs 200-201, 247-256)
- DB: 195 features → complete (387-581); 32 tests → complete;
  81 server-dependent tests → n/a

Features: 667 complete, 274 unit tests complete (17.2% overall)
2026-02-26 13:50:38 -05:00
Joseph Doherty
88b1391ef0 feat: port session 07 — Protocol Parser, Auth extras (TPM/certidp/certstore), Internal utilities & data structures
Session 07 scope (5 features, 17 tests, ~1165 Go LOC):
- Protocol/ParserTypes.cs: ParserState enum (79 states), PublishArgument, ParseContext
- Protocol/IProtocolHandler.cs: handler interface decoupling parser from client
- Protocol/ProtocolParser.cs: Parse(), ProtoSnippet(), OverMaxControlLineLimit(),
  ProcessPub/HeaderPub/RoutedMsgArgs/RoutedHeaderMsgArgs, ClonePubArg(), GetHeader()
- tests/Protocol/ProtocolParserTests.cs: 17 tests via TestProtocolHandler stub

Auth extras from session 06 (committed separately):
- Auth/TpmKeyProvider.cs, Auth/CertificateIdentityProvider/, Auth/CertificateStore/

Internal utilities & data structures (session 06 overflow):
- Internal/AccessTimeService.cs, ElasticPointer.cs, SystemMemory.cs, ProcessStatsProvider.cs
- Internal/DataStructures/GenericSublist.cs, HashWheel.cs
- Internal/DataStructures/SubjectTree.cs, SubjectTreeNode.cs, SubjectTreeParts.cs

All 461 tests pass (460 unit + 1 integration). DB updated for features 2588-2592 and tests 2598-2614.
2026-02-26 13:16:56 -05:00
Joseph Doherty
0a54d342ba feat: port session 06 — Authentication & JWT types, validators, cipher suites
Port independently-testable auth functions from auth.go, ciphersuites.go,
and jwt.go. Server-dependent methods (configureAuthorization, checkAuthentication,
auth callout, etc.) are stubbed for later sessions.

- AuthTypes: User, NkeyUser, SubjectPermission, ResponsePermission, Permissions,
  RoutePermissions, Account — all with deep Clone() methods
- AuthHandler: IsBcrypt, ComparePasswords, ValidateResponsePermissions,
  ValidateAllowedConnectionTypes, ValidateNoAuthUser, ValidateAuth,
  DnsAltNameLabels, DnsAltNameMatches, WipeSlice, ConnectionTypes constants
- CipherSuites: CipherMap, CipherMapById, DefaultCipherSuites,
  CurvePreferenceMap, DefaultCurvePreferences
- JwtProcessor: JwtPrefix, WipeSlice, ValidateSrc (CIDR matching),
  ValidateTimes (time-of-day ranges), TimeRange type
- ServerOptions: added Users, Nkeys, TrustedOperators properties
- 67 new unit tests (all 328 tests pass)
- DB: 18 features complete, 25 stubbed; 6 Go tests complete, 125 stubbed
2026-02-26 12:27:33 -05:00
Joseph Doherty
ed78a100e2 feat: port session 05 — Subscription Index (sublist)
Port trie-based subject matching engine (81 features, 74 tests).
Includes SubscriptionIndex with cache, wildcard matching (*/>),
queue subscription groups, reverse match, notifications, stats,
and subject validation utilities. Also adds minimal Subscription
and NatsClient stubs needed by the index.
2026-02-26 12:11:06 -05:00
Joseph Doherty
b8f2f66d45 feat: port session 04 — Logging, Signals & Services
- NatsLogger.cs: INatsLogger interface (Noticef/Warnf/Fatalf/Errorf/Debugf/Tracef),
  ServerLogging state class with atomic debug/trace flags, rate-limited logging
  (RateLimitWarnf/RateLimitDebugf), error variants (Errors/Errorc/Errorsc),
  MicrosoftLoggerAdapter bridging to ILogger
- SignalHandler.cs: ProcessSignal (Unix kill via Process), CommandToUnixSignal mapping
  (Stop→SIGKILL, Quit→SIGINT, Reopen→SIGUSR1, Reload→SIGHUP), ResolvePids via pgrep,
  SetProcessName, Run/IsWindowsService stubs for non-Windows
- 11 tests (6 logger, 5 signal/service)
- WASM/Windows signal stubs already n/a
- All 141 tests pass (140 unit + 1 integration)
- DB: features 368/3673 complete, tests 155/3257 complete (9.6% overall)
2026-02-26 11:54:25 -05:00
Joseph Doherty
f08fc5d6a7 feat: port session 03 — Configuration & Options types, Clone, MergeOptions, SetBaseline
- ServerOptionTypes.cs: all supporting types — ClusterOpts, GatewayOpts, LeafNodeOpts,
  WebsocketOpts, MqttOpts, RemoteLeafOpts, RemoteGatewayOpts, CompressionOpts,
  TlsConfigOpts, JsLimitOpts, JsTpmOpts, AuthCalloutOpts, ProxiesConfig,
  IAuthentication, IAccountResolver, enums (WriteTimeoutPolicy, StoreCipher, OcspMode)
- ServerOptions.cs: full Options struct with ~100 properties across 10 subsystems
  (general, logging, networking, TLS, cluster, gateway, leafnode, websocket, MQTT, JetStream)
- ServerOptions.Methods.cs: Clone (deep copy), MergeOptions, SetBaselineOptions,
  RoutesFromStr, NormalizeBasePath, OverrideTls, OverrideCluster, ExpandPath,
  HomeDir, MaybeReadPidFile, GetDefaultAuthTimeout, ConfigFlags.NoErrOnUnknownFields
- 17 tests covering defaults, random port, merge, clone, expand path, auth timeout,
  routes parsing, normalize path, cluster override, config flags
- Config file parsing (processConfigFileLine 765-line function) deferred to follow-up
- All 130 tests pass (129 unit + 1 integration)
- DB: features 344/3673 complete, tests 148/3257 complete (9.1% overall)
2026-02-26 11:51:01 -05:00
Joseph Doherty
11c0b92fbd feat: port session 02 — Utilities & Queues (util, ipqueue, scheduler, subject_transform)
- ServerUtilities: version helpers, parseSize/parseInt64, parseHostPort, URL redaction,
  comma formatting, refCountedUrlSet, TCP helpers, parallelTaskQueue
- IpQueue<T>: generic intra-process queue with 1-slot Channel<bool> notification signal,
  optional size/len limits, ConcurrentDictionary registry, single-slot List<T> pool
- MsgScheduling: per-subject scheduled message tracking via HashWheel TTLs,
  binary encode/decode with zigzag varint, Timer-based firing
- SubjectTransform: full NATS subject mapping engine (11 transform types: Wildcard,
  Partition, SplitFromLeft, SplitFromRight, SliceFromLeft, SliceFromRight, Split,
  Left, Right, Random, NoTransform), FNV-1a partition hash
- 20 tests (7 util, 9 ipqueue, 4 subject_transform); 45 benchmarks/split tests marked n/a
- All 113 tests pass (112 unit + 1 integration)
- DB: features 328/3673 complete, tests 139/3257 complete (8.7% overall)
2026-02-26 09:39:36 -05:00
Joseph Doherty
8050ee1897 feat: port session 01 — Foundation Types (const, errors, proto, ring, rate_counter, sdm)
Ports server/const.go, errors.go, proto.go, ring.go, rate_counter.go, sdm.go.
- ServerConstants: all protocol constants and version info from const.go
- ServerErrors: ~60 sentinel exceptions plus errCtx/configErr/processConfigErr types
- ProtoWire: protobuf varint encode/decode helpers (proto.go)
- RateCounter: sliding-window rate limiter (rate_counter.go)
- ClosedRingBuffer: fixed-size ring buffer for /connz (ring.go)
- StreamDeletionMeta: SDM tracking for JetStream cluster consensus (sdm.go)
- 5 unit tests passing (errors, ring buffer, rate counter)
- errors_gen.go (code generator tool) and nkey.go Server methods marked n_a
2026-02-26 09:15:20 -05:00
Joseph Doherty
66628bc25a feat: port avl module - SequenceSet AVL tree (36 features, 17 tests) 2026-02-26 08:07:54 -05:00
Joseph Doherty
b335230498 chore: scaffold .NET solution structure for Phase 6
Creates the four-project .NET 10 solution (ZB.MOM.NatsNet.slnx):
- ZB.MOM.NatsNet.Server: main library with MEL, BCrypt.Net-Next, IronSnappy
- ZB.MOM.NatsNet.Server.Host: console host with Serilog + Microsoft.Extensions.Hosting
- ZB.MOM.NatsNet.Server.Tests: xUnit 2.9 / Shouldly / NSubstitute / BenchmarkDotNet
- ZB.MOM.NatsNet.Server.IntegrationTests: same test stack, separate project

Also adds Phase 6 porting plan and task-tracking JSON.
2026-02-26 08:03:21 -05:00
119 changed files with 25426 additions and 6 deletions

View File

@@ -0,0 +1,741 @@
# Phase 6: Initial Porting Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Port all 3632 Go features and 3257 tests across 12 modules from the Go NATS server to idiomatic .NET 10 C#, working bottom-up through the dependency graph.
**Architecture:** Port leaf modules (11 small modules, 279 features total) in parallel first, then tackle the server module (3394 features) in functional batches. The `dependency ready` command drives porting order. Every feature gets a stub before implementation; tests are ported alongside.
**Tech Stack:** .NET 10, C# latest, xUnit 3, Shouldly, NSubstitute, Microsoft.Extensions.Logging, Serilog, System.IO.Pipelines, BCrypt.Net-Next, IronSnappy, Tpm2Lib
---
## Dependency Graph Summary
```
Leaf modules (all unblocked — port in parallel):
ats module 1 4 features, 3 tests → AccessTimeService
avl module 2 36 features, 16 tests → SequenceSet (AVL tree)
certidp module 3 14 features, 2 tests → CertificateIdentityProvider
certstore module 4 36 features, 0 tests → CertificateStore
elastic module 5 5 features, 0 tests → ElasticEncoding
gsl module 6 26 features, 21 tests → GenericSubjectList
pse module 7 28 features, 3 tests → ProcessStatsProvider
stree module 9 101 features, 59 tests → SubjectTree
sysmem module 10 9 features, 0 tests → SystemMemory
thw module 11 12 features, 14 tests → TimeHashWheel
tpm module 12 8 features, 2 tests → TpmKeyProvider
Server module (blocked on all leaf modules):
server module 8 3394 features, ~3137 tests → NatsServer (347K Go LOC)
```
---
## TDD Porting Pattern (repeat for every feature)
1. `dotnet run --project tools/NatsNet.PortTracker -- feature update <id> --status stub --db porting.db`
2. Write failing test (translate Go test, Shouldly assertions, xUnit `[Fact]`/`[Theory]`)
3. Run test: confirm FAIL
4. Implement feature: idiomatic C# from Go source (coordinates in `feature show <id>`)
5. Run test: confirm PASS
6. `dotnet run --project tools/NatsNet.PortTracker -- feature update <id> --status complete --db porting.db`
After all features in a module: `dotnet run --project tools/NatsNet.PortTracker -- module update <id> --status complete --db porting.db`
---
## Go→.NET Translation Reference
| Go pattern | .NET equivalent |
|-----------|-----------------|
| `goroutine` + `channel` | `Task` + `Channel<T>` or `async/await` |
| `sync.Mutex` | `lock` or `SemaphoreSlim` |
| `sync.RWMutex` | `ReaderWriterLockSlim` |
| `sync.WaitGroup` | `Task.WhenAll` or `CountdownEvent` |
| `defer` | `try/finally` or `using` |
| `interface{}` / `any` | `object` or generics |
| `[]byte` | `byte[]`, `ReadOnlySpan<byte>`, or `ReadOnlyMemory<byte>` |
| `map[K]V` | `Dictionary<K,V>` or `ConcurrentDictionary<K,V>` |
| `error` return | Exceptions or `Result<T>` |
| `panic/recover` | Exceptions |
| `select` on channels | `Task.WhenAny` or `Channel<T>` reader |
| `context.Context` | `CancellationToken` |
| `io.Reader/Writer` | `Stream`, `PipeReader/PipeWriter` |
| `init()` | Static constructor or DI registration |
---
## Task 0: Create .NET Solution Structure
**Files to create:**
- `dotnet/ZB.MOM.NatsNet.sln`
- `dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj`
- `dotnet/src/ZB.MOM.NatsNet.Server.Host/ZB.MOM.NatsNet.Server.Host.csproj`
- `dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj`
- `dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/ZB.MOM.NatsNet.Server.IntegrationTests.csproj`
**Step 1: Scaffold projects**
```bash
cd /Users/dohertj2/Desktop/natsnet/dotnet
dotnet new sln -n ZB.MOM.NatsNet
dotnet new classlib -n ZB.MOM.NatsNet.Server -o src/ZB.MOM.NatsNet.Server --framework net10.0
dotnet new console -n ZB.MOM.NatsNet.Server.Host -o src/ZB.MOM.NatsNet.Server.Host --framework net10.0
dotnet new xunit -n ZB.MOM.NatsNet.Server.Tests -o tests/ZB.MOM.NatsNet.Server.Tests --framework net10.0
dotnet new xunit -n ZB.MOM.NatsNet.Server.IntegrationTests -o tests/ZB.MOM.NatsNet.Server.IntegrationTests --framework net10.0
dotnet sln add src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj
dotnet sln add src/ZB.MOM.NatsNet.Server.Host/ZB.MOM.NatsNet.Server.Host.csproj
dotnet sln add tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj
dotnet sln add tests/ZB.MOM.NatsNet.Server.IntegrationTests/ZB.MOM.NatsNet.Server.IntegrationTests.csproj
```
**Step 2: Configure server library .csproj**
Replace contents of `dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="*" />
<PackageReference Include="Microsoft.Extensions.Options" Version="*" />
<PackageReference Include="System.IO.Pipelines" Version="*" />
<PackageReference Include="BCrypt.Net-Next" Version="*" />
<PackageReference Include="IronSnappy" Version="*" />
</ItemGroup>
</Project>
```
Note: Tpm2Lib is Windows-only; add it in Task 11 for the tpm module with a conditional reference.
**Step 3: Configure Host .csproj**
Replace contents of `dotnet/src/ZB.MOM.NatsNet.Server.Host/ZB.MOM.NatsNet.Server.Host.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="*" />
<PackageReference Include="Serilog" Version="*" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="*" />
<PackageReference Include="Serilog.Sinks.Console" Version="*" />
</ItemGroup>
</Project>
```
**Step 4: Configure Tests .csproj**
Replace contents of `dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj" />
<PackageReference Include="xunit" Version="3.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="*" />
<PackageReference Include="Shouldly" Version="*" />
<PackageReference Include="NSubstitute" Version="*" />
<PackageReference Include="BenchmarkDotNet" Version="*" />
</ItemGroup>
</Project>
```
Apply the same to `dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/ZB.MOM.NatsNet.Server.IntegrationTests.csproj`.
**Step 5: Clean boilerplate, add placeholder Program.cs, verify build**
```bash
rm -f dotnet/src/ZB.MOM.NatsNet.Server/Class1.cs
# Program.cs for Host (placeholder)
# Write: Console.WriteLine("ZB.MOM.NatsNet.Server"); to Program.cs
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/ZB.MOM.NatsNet.sln
dotnet test /Users/dohertj2/Desktop/natsnet/dotnet/ZB.MOM.NatsNet.sln
```
Expected: build succeeds, 0 tests (or default xUnit tests pass).
**Step 6: Commit**
```bash
git add dotnet/
git commit -m "chore: scaffold .NET solution structure for Phase 6"
```
---
## Task 1: Port `avl` Module — SequenceSet
**Go source:** `golang/nats-server/server/avl/seqset.go` (678 LOC)
**Go tests:** `golang/nats-server/server/avl/seqset_test.go`, `norace_test.go`
**Target:** `dotnet/src/ZB.MOM.NatsNet.Server/Internal/DataStructures/SequenceSet.cs`
**Tests:** `dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/DataStructures/SequenceSetTests.cs`
**Namespace:** `ZB.MOM.NatsNet.Server.Internal.DataStructures`
**Features:** 36 (IDs 540) | **Tests:** 16 | **Module ID:** 2
**Step 1: Mark all stubs**
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status stub --all-in-module 2 --db porting.db
```
**Step 2: Lookup source coordinates for key features**
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature show 5 --db porting.db # Insert (line 44)
dotnet run --project tools/NatsNet.PortTracker -- feature show 6 --db porting.db # Exists
dotnet run --project tools/NatsNet.PortTracker -- feature show 8 --db porting.db # Delete
dotnet run --project tools/NatsNet.PortTracker -- feature show 22 --db porting.db # Encode
dotnet run --project tools/NatsNet.PortTracker -- feature show 23 --db porting.db # Decode
```
**Step 3: Create the class skeleton**
Create `dotnet/src/ZB.MOM.NatsNet.Server/Internal/DataStructures/SequenceSet.cs`.
Key type mappings:
- `SequenceSet` struct → `public sealed class SequenceSet`
- `node` struct → `private sealed class Node`
- Go `uint64` → C# `ulong`
- Go pointer receivers on `node` → C# methods on `Node`
All methods: `throw new NotImplementedException()` initially.
**Step 4: Write failing tests**
Create `SequenceSetTests.cs` translating from `seqset_test.go`. Example:
```csharp
// Go: TestSeqSetBasics
[Fact]
public void SeqSetBasics_ShouldSucceed()
{
var ss = new SequenceSet();
ss.IsEmpty().ShouldBeTrue();
ss.Insert(1);
ss.Exists(1).ShouldBeTrue();
ss.Exists(2).ShouldBeFalse();
ss.Size().ShouldBe(1);
ss.Delete(1);
ss.IsEmpty().ShouldBeTrue();
}
```
Run: `dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ --filter "FullyQualifiedName~SequenceSetTests"`
Expected: FAIL with NotImplementedException.
**Step 5: Implement SequenceSet**
Port AVL tree logic from `seqset.go`. Critical Go→.NET:
- `uint64``ulong`
- nil checks → null checks
- `Range(f func(uint64, uint64) bool)``Range(Func<ulong, ulong, bool> f)`
- Encode/Decode using `ReadOnlySpan<byte>` and `BinaryPrimitives`
**Step 6: Run tests**
```bash
dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ --filter "FullyQualifiedName~SequenceSetTests"
```
Expected: all 16 tests pass.
**Step 7: Update DB**
```bash
for id in 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40; do
dotnet run --project tools/NatsNet.PortTracker -- feature update $id --status complete --db porting.db
done
dotnet run --project tools/NatsNet.PortTracker -- test list --module 2 --db porting.db
# mark each test complete
dotnet run --project tools/NatsNet.PortTracker -- module update 2 --status complete --db porting.db
```
**Step 8: Commit**
```bash
git add dotnet/
git commit -m "feat: port avl module - SequenceSet AVL tree"
```
---
## Task 2: Port `ats` Module — AccessTimeService
**Go source:** `golang/nats-server/server/ats/ats.go` (186 LOC)
**Go tests:** `golang/nats-server/server/ats/ats_test.go`
**Target:** `dotnet/src/ZB.MOM.NatsNet.Server/Internal/AccessTimeService.cs`
**Tests:** `dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Internal/AccessTimeServiceTests.cs`
**Namespace:** `ZB.MOM.NatsNet.Server.Internal`
**Features:** IDs 14 | **Tests:** IDs 13 | **Module ID:** 1
Tracks access times; `init()` → static initialization. Follow TDD pattern.
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status stub --all-in-module 1 --db porting.db
# implement Register, Unregister, AccessTime, Init
for id in 1 2 3 4; do dotnet run --project tools/NatsNet.PortTracker -- feature update $id --status complete --db porting.db; done
dotnet run --project tools/NatsNet.PortTracker -- module update 1 --status complete --db porting.db
git commit -m "feat: port ats module - AccessTimeService"
```
---
## Task 3: Port `elastic` Module — ElasticEncoding
**Go source:** `golang/nats-server/server/elastic/elastic.go` (61 LOC)
**Target:** `dotnet/src/ZB.MOM.NatsNet.Server/Internal/ElasticEncoding.cs`
**Namespace:** `ZB.MOM.NatsNet.Server.Internal`
**Features:** 5 | **Tests:** 0 | **Module ID:** 5
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status stub --all-in-module 5 --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 5 --db porting.db
# implement features; no tests to port
dotnet run --project tools/NatsNet.PortTracker -- module update 5 --status complete --db porting.db
git commit -m "feat: port elastic module - ElasticEncoding"
```
---
## Task 4: Port `sysmem` Module — SystemMemory
**Go source:** `golang/nats-server/server/sysmem/mem_*.go` (platform-specific)
**Target:** `dotnet/src/ZB.MOM.NatsNet.Server/Internal/SystemMemory.cs`
**Namespace:** `ZB.MOM.NatsNet.Server.Internal`
**Features:** 9 | **Tests:** 0 | **Module ID:** 10
Use `System.Diagnostics.Process` and `GC.GetTotalMemory` for cross-platform memory. Mark platform-specific Go variants (BSD, Solaris, WASM, z/OS) as N/A with reason.
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status stub --all-in-module 10 --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 10 --db porting.db
# implement; mark n/a for platform variants
dotnet run --project tools/NatsNet.PortTracker -- module update 10 --status complete --db porting.db
git commit -m "feat: port sysmem module - SystemMemory"
```
---
## Task 5: Port `thw` Module — TimeHashWheel
**Go source:** `golang/nats-server/server/thw/` (656 LOC)
**Target:** `dotnet/src/ZB.MOM.NatsNet.Server/Internal/TimeHashWheel.cs`
**Namespace:** `ZB.MOM.NatsNet.Server.Internal`
**Features:** 12 | **Tests:** 14 | **Module ID:** 11
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status stub --all-in-module 11 --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- test list --module 11 --db porting.db
# TDD: write tests first, then implement hash wheel
dotnet run --project tools/NatsNet.PortTracker -- module update 11 --status complete --db porting.db
git commit -m "feat: port thw module - TimeHashWheel"
```
---
## Task 6: Port `certidp` Module — CertificateIdentityProvider
**Go source:** `golang/nats-server/server/certidp/` (600 LOC)
**Target:** `dotnet/src/ZB.MOM.NatsNet.Server/Auth/CertificateIdentityProvider.cs`
**Namespace:** `ZB.MOM.NatsNet.Server.Auth`
**Features:** 14 | **Tests:** 2 | **Module ID:** 3
Use `System.Security.Cryptography.X509Certificates`.
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status stub --all-in-module 3 --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- module update 3 --status complete --db porting.db
git commit -m "feat: port certidp module - CertificateIdentityProvider"
```
---
## Task 7: Port `certstore` Module — CertificateStore
**Go source:** `golang/nats-server/server/certstore/` (1197 LOC)
**Target:** `dotnet/src/ZB.MOM.NatsNet.Server/Auth/CertificateStore.cs`
**Namespace:** `ZB.MOM.NatsNet.Server.Auth`
**Features:** 36 | **Tests:** 0 | **Module ID:** 4
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status stub --all-in-module 4 --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- module update 4 --status complete --db porting.db
git commit -m "feat: port certstore module - CertificateStore"
```
---
## Task 8: Port `gsl` Module — GenericSubjectList
**Go source:** `golang/nats-server/server/gsl/` (936 LOC)
**Target:** `dotnet/src/ZB.MOM.NatsNet.Server/Subscriptions/GenericSubjectList.cs`
**Namespace:** `ZB.MOM.NatsNet.Server.Subscriptions`
**Features:** 26 | **Tests:** 21 | **Module ID:** 6
Performance-sensitive. Use `ReadOnlySpan<byte>` for subject matching.
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status stub --all-in-module 6 --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- test list --module 6 --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- module update 6 --status complete --db porting.db
git commit -m "feat: port gsl module - GenericSubjectList"
```
---
## Task 9: Port `pse` Module — ProcessStatsProvider
**Go source:** `golang/nats-server/server/pse/` (1150 LOC, platform-specific)
**Target:** `dotnet/src/ZB.MOM.NatsNet.Server/Internal/ProcessStatsProvider.cs`
**Namespace:** `ZB.MOM.NatsNet.Server.Internal`
**Features:** 28 | **Tests:** 3 | **Module ID:** 7
Use `System.Diagnostics.Process`. Mark Go-specific syscall wrappers N/A where replaced.
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status stub --all-in-module 7 --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- module update 7 --status complete --db porting.db
git commit -m "feat: port pse module - ProcessStatsProvider"
```
---
## Task 10: Port `stree` Module — SubjectTree
**Go source:** `golang/nats-server/server/stree/` (3628 LOC)
**Target:** `dotnet/src/ZB.MOM.NatsNet.Server/Subscriptions/SubjectTree.cs`
**Namespace:** `ZB.MOM.NatsNet.Server.Subscriptions`
**Features:** 101 | **Tests:** 59 | **Module ID:** 9
Largest leaf module. Performance-critical NATS routing trie. Use `ReadOnlySpan<byte>` throughout.
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status stub --all-in-module 9 --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- test list --module 9 --db porting.db
# write 59 tests first, implement trie, iterate until all pass
dotnet run --project tools/NatsNet.PortTracker -- module update 9 --status complete --db porting.db
git commit -m "feat: port stree module - SubjectTree"
```
---
## Task 11: Port `tpm` Module — TpmKeyProvider
**Go source:** `golang/nats-server/server/tpm/` (387 LOC)
**Target:** `dotnet/src/ZB.MOM.NatsNet.Server/Auth/TpmKeyProvider.cs`
**Namespace:** `ZB.MOM.NatsNet.Server.Auth`
**Features:** 8 | **Tests:** 2 | **Module ID:** 12
Add conditional Tpm2Lib reference (Windows-only). If unavailable on current platform, throw `PlatformNotSupportedException`.
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status stub --all-in-module 12 --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- module update 12 --status complete --db porting.db
git commit -m "feat: port tpm module - TpmKeyProvider"
```
---
## Task 12: Verify Wave 1 (All Leaf Modules Complete)
```bash
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- dependency ready --db porting.db | head -5
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/ZB.MOM.NatsNet.sln
dotnet test /Users/dohertj2/Desktop/natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/
```
Expected: 11 modules complete, server module appears in `dependency ready`, build green.
---
## Task 13: Port Server Module — Batch A: Core Types
**Go sources:** `server/const.go`, `server/errors.go`, `server/errors_gen.go`, `server/proto.go`, `server/util.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/Constants.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/NatsErrors.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ProtocolConstants.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Internal/Utilities.cs`
```bash
dotnet run --project tools/NatsNet.PortTracker -- dependency ready --db porting.db
# identify IDs for const/errors/proto/util features; stub them
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/ZB.MOM.NatsNet.sln
git commit -m "feat: port server core types (const, errors, proto, util)"
```
---
## Task 14: Port Server Module — Batch B: Options & Config
**Go sources:** `server/opts.go`, `server/reload.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/Configuration/NatsServerOptions.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Configuration/NatsServerReload.cs`
```bash
git commit -m "feat: port server options and reload configuration"
```
---
## Task 15: Port Server Module — Batch C: Parser & Protocol
**Go sources:** `server/parser.go`, `server/ring.go`, `server/rate_counter.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/Protocol/NatsParser.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Internal/RingBuffer.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Internal/RateCounter.cs`
Parser is performance-critical. Use `ReadOnlySpan<byte>` and `System.IO.Pipelines`.
```bash
git commit -m "feat: port server parser, ring buffer, rate counter"
```
---
## Task 16: Port Server Module — Batch D: Client & Connection
**Go sources:** `server/client.go`, `server/client_proxyproto.go`, `server/sendq.go`, `server/ipqueue.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/Connections/NatsClient.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Connections/ProxyProtocolHandler.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Connections/SendQueue.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Internal/IpQueue.cs`
Use `Channel<T>` for send queue. All async I/O via `PipeWriter`/`PipeReader`.
```bash
git commit -m "feat: port server client, connection, send queue"
```
---
## Task 17: Port Server Module — Batch E: Auth & Security
**Go sources:** `server/auth.go`, `server/auth_callout.go`, `server/jwt.go`, `server/nkey.go`, `server/ciphersuites.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthCallout.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtValidator.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Auth/NkeyProvider.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Auth/CipherSuiteMapper.cs`
```bash
git commit -m "feat: port server auth, JWT, nkeys, cipher suites"
```
---
## Task 18: Port Server Module — Batch F: Accounts & Events
**Go sources:** `server/accounts.go`, `server/events.go`, `server/dirstore.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/Auth/AccountManager.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Events/NatsEventBus.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Auth/DirectoryAccountStore.cs`
```bash
git commit -m "feat: port server accounts, events, directory store"
```
---
## Task 19: Port Server Module — Batch G: Sublist & Subject Transform
**Go sources:** `server/sublist.go`, `server/subject_transform.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/Subscriptions/SubList.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Subscriptions/SubjectTransform.cs`
Hot path. Use `ReadOnlySpan<byte>`, avoid allocations in `Match`.
```bash
git commit -m "feat: port server sublist and subject transform"
```
---
## Task 20: Port Server Module — Batch H: Clustering
**Go sources:** `server/route.go`, `server/gateway.go`, `server/leafnode.go`, `server/raft.go`, `server/sdm.go`, `server/scheduler.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/Cluster/RouteHandler.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Cluster/GatewayHandler.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Cluster/LeafNodeHandler.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Cluster/RaftConsensus.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Cluster/StreamDomainManager.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Cluster/Scheduler.cs`
```bash
git commit -m "feat: port server clustering (routes, gateway, leaf nodes, raft)"
```
---
## Task 21: Port Server Module — Batch I: JetStream Core
**Go sources:** `server/jetstream.go`, `server/jetstream_api.go`, `server/jetstream_errors.go`, `server/jetstream_errors_generated.go`, `server/jetstream_events.go`, `server/jetstream_versioning.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEngine.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamApi.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamErrors.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamEvents.cs`
```bash
git commit -m "feat: port JetStream core engine and API"
```
---
## Task 22: Port Server Module — Batch J: JetStream Storage
**Go sources:** `server/store.go`, `server/filestore.go`, `server/memstore.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/Storage/IMessageStore.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/Storage/FileMessageStore.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/Storage/MemoryMessageStore.cs`
Define `IMessageStore` from `store.go` first, then implement.
```bash
git commit -m "feat: port JetStream storage (file store, memory store)"
```
---
## Task 23: Port Server Module — Batch K: JetStream Streams & Consumers
**Go sources:** `server/stream.go`, `server/consumer.go`, `server/jetstream_batching.go`, `server/jetstream_cluster.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/StreamManager.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/ConsumerManager.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/BatchProcessor.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/JetStreamCluster.cs`
```bash
git commit -m "feat: port JetStream streams, consumers, batching, cluster"
```
---
## Task 24: Port Server Module — Batch L: Monitoring
**Go sources:** `server/monitor.go`, `server/monitor_sort_opts.go`, `server/msgtrace.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/Monitoring/NatsMonitor.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Monitoring/MonitorSortOptions.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Monitoring/MessageTracer.cs`
`log.go` features → mark N/A: replaced by `Microsoft.Extensions.Logging` + Serilog.
```bash
git commit -m "feat: port server monitoring, message tracing (log.go → N/A)"
```
---
## Task 25: Port Server Module — Batch M: MQTT & WebSocket
**Go sources:** `server/mqtt.go`, `server/websocket.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/Protocol/MqttHandler.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Protocol/WebSocketHandler.cs`
```bash
git commit -m "feat: port MQTT and WebSocket protocol handlers"
```
---
## Task 26: Port Server Module — Batch N: OCSP & TLS
**Go sources:** `server/ocsp.go`, `server/ocsp_peer.go`, `server/ocsp_responsecache.go`
**Targets:**
- `dotnet/src/ZB.MOM.NatsNet.Server/Auth/OcspValidator.cs`
- `dotnet/src/ZB.MOM.NatsNet.Server/Auth/OcspResponseCache.cs`
```bash
git commit -m "feat: port OCSP validation and response cache"
```
---
## Task 27: Port Server Module — Batch O: Platform-Specific & N/A
**Go sources:** `server/disk_avail_*.go`, `server/signal.go`, `server/signal_*.go`, `server/service.go`, `server/service_windows.go`
- Signal handling → mark N/A: replaced by `IHostApplicationLifetime`
- Windows service → port using `System.ServiceProcess`
- Disk availability → port using `System.IO.DriveInfo`; platform variants → N/A
```bash
git commit -m "feat: port platform-specific features, mark N/A where replaced"
```
---
## Task 28: Port Server Module — Batch P: Server Core
**Go source:** `server/server.go`
**Target:** `dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs`
Main `Server` struct and lifecycle. Final piece of the server module.
```bash
dotnet run --project tools/NatsNet.PortTracker -- module update 8 --status complete --db porting.db
git commit -m "feat: port server core lifecycle - server module complete"
```
---
## Task 29: Final Verification & Close Phase 6
```bash
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/ZB.MOM.NatsNet.sln
dotnet test /Users/dohertj2/Desktop/natsnet/dotnet/ZB.MOM.NatsNet.sln
dotnet run --project tools/NatsNet.PortTracker -- dependency blocked --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- phase check 6 --db porting.db
# Close Gitea issues 39-44
for issue in 39 40 41 42 43 44; do
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/issues/$issue" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
done
# Close Phase 6 milestone
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/milestones/6" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
git commit -m "chore: complete phase 6 - initial porting complete"
```

View File

@@ -0,0 +1,36 @@
{
"planPath": "docs/plans/2026-02-26-phase-6-porting.md",
"tasks": [
{"id": 0, "subject": "Task 0: Create .NET Solution Structure", "status": "pending"},
{"id": 1, "subject": "Task 1: Port avl module - SequenceSet", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: Port ats module - AccessTimeService", "status": "pending", "blockedBy": [0]},
{"id": 3, "subject": "Task 3: Port elastic module - ElasticEncoding", "status": "pending", "blockedBy": [0]},
{"id": 4, "subject": "Task 4: Port sysmem module - SystemMemory", "status": "pending", "blockedBy": [0]},
{"id": 5, "subject": "Task 5: Port thw module - TimeHashWheel", "status": "pending", "blockedBy": [0]},
{"id": 6, "subject": "Task 6: Port certidp module - CertificateIdentityProvider", "status": "pending", "blockedBy": [0]},
{"id": 7, "subject": "Task 7: Port certstore module - CertificateStore", "status": "pending", "blockedBy": [0]},
{"id": 8, "subject": "Task 8: Port gsl module - GenericSubjectList", "status": "pending", "blockedBy": [0]},
{"id": 9, "subject": "Task 9: Port pse module - ProcessStatsProvider", "status": "pending", "blockedBy": [0]},
{"id": 10, "subject": "Task 10: Port stree module - SubjectTree", "status": "pending", "blockedBy": [0]},
{"id": 11, "subject": "Task 11: Port tpm module - TpmKeyProvider", "status": "pending", "blockedBy": [0]},
{"id": 12, "subject": "Task 12: Verify Wave 1 (all leaf modules complete)", "status": "pending", "blockedBy": [1,2,3,4,5,6,7,8,9,10,11]},
{"id": 13, "subject": "Task 13: Port server Batch A - Core Types", "status": "pending", "blockedBy": [12]},
{"id": 14, "subject": "Task 14: Port server Batch B - Options & Config", "status": "pending", "blockedBy": [13]},
{"id": 15, "subject": "Task 15: Port server Batch C - Parser & Protocol", "status": "pending", "blockedBy": [14]},
{"id": 16, "subject": "Task 16: Port server Batch D - Client & Connection", "status": "pending", "blockedBy": [15]},
{"id": 17, "subject": "Task 17: Port server Batch E - Auth & Security", "status": "pending", "blockedBy": [16]},
{"id": 18, "subject": "Task 18: Port server Batch F - Accounts & Events", "status": "pending", "blockedBy": [17]},
{"id": 19, "subject": "Task 19: Port server Batch G - Sublist & Subject Transform", "status": "pending", "blockedBy": [18]},
{"id": 20, "subject": "Task 20: Port server Batch H - Clustering", "status": "pending", "blockedBy": [19]},
{"id": 21, "subject": "Task 21: Port server Batch I - JetStream Core", "status": "pending", "blockedBy": [20]},
{"id": 22, "subject": "Task 22: Port server Batch J - JetStream Storage", "status": "pending", "blockedBy": [21]},
{"id": 23, "subject": "Task 23: Port server Batch K - JetStream Streams & Consumers", "status": "pending", "blockedBy": [22]},
{"id": 24, "subject": "Task 24: Port server Batch L - Monitoring", "status": "pending", "blockedBy": [23]},
{"id": 25, "subject": "Task 25: Port server Batch M - MQTT & WebSocket", "status": "pending", "blockedBy": [24]},
{"id": 26, "subject": "Task 26: Port server Batch N - OCSP & TLS", "status": "pending", "blockedBy": [25]},
{"id": 27, "subject": "Task 27: Port server Batch O - Platform-Specific & N/A", "status": "pending", "blockedBy": [26]},
{"id": 28, "subject": "Task 28: Port server Batch P - Server Core", "status": "pending", "blockedBy": [27]},
{"id": 29, "subject": "Task 29: Final Verification & Close Phase 6", "status": "pending", "blockedBy": [28]}
],
"lastUpdated": "2026-02-26T00:00:00Z"
}

View File

@@ -194,6 +194,10 @@ After leaves are done, modules that depended only on those leaves become ready.
Leaf utilities -> Protocol types -> Parser -> Connection handler -> Server
```
### Server module session plan
The server module (~103K Go LOC, 3,394 features, 3,137 tests) is too large for a single pass. It has been broken into **23 sessions** with dependency ordering and sub-batching guidance. See [phase6sessions/readme.md](phase6sessions/readme.md) for the full session map, dependency graph, and execution instructions.
### Port tests alongside features
When porting a feature, also port its associated tests in the same pass. This provides immediate validation:

View File

@@ -0,0 +1,143 @@
# Phase 6 Sessions: Server Module Breakdown
The server module (module 8) contains **3,394 features**, **3,137 unit tests**, and **~103K Go LOC** across 64 source files. It has been split into **23 sessions** targeting ~5K Go LOC each, ordered by dependency (bottom-up).
## Session Map
| Session | Name | Go LOC | Features | Tests | Go Files |
|---------|------|--------|----------|-------|----------|
| [01](session-01.md) | Foundation Types | 626 | 46 | 17 | const, errors, errors_gen, proto, ring, rate_counter, sdm, nkey |
| [02](session-02.md) | Utilities & Queues | 1,325 | 68 | 57 | util, ipqueue, sendq, scheduler, subject_transform |
| [03](session-03.md) | Configuration & Options | 5,400 | 86 | 89 | opts |
| [04](session-04.md) | Logging, Signals & Services | 534 | 34 | 27 | log, signal*, service* |
| [05](session-05.md) | Subscription Index | 1,416 | 81 | 96 | sublist |
| [06](session-06.md) | Auth & JWT | 2,196 | 43 | 131 | auth, auth_callout, jwt, ciphersuites |
| [07](session-07.md) | Protocol Parser | 1,165 | 5 | 17 | parser |
| [08](session-08.md) | Client Connection | 5,953 | 195 | 113 | client, client_proxyproto |
| [09](session-09.md) | Server Core — Init & Config | ~1,950 | ~76 | ~20 | server.go (first half) |
| [10](session-10.md) | Server Core — Runtime & Lifecycle | ~1,881 | ~98 | ~27 | server.go (second half) |
| [11](session-11.md) | Accounts & Directory Store | 4,493 | 234 | 84 | accounts, dirstore |
| [12](session-12.md) | Events, Monitoring & Tracing | 6,319 | 218 | 188 | events, monitor, monitor_sort_opts, msgtrace |
| [13](session-13.md) | Configuration Reload | 2,085 | 89 | 73 | reload |
| [14](session-14.md) | Routes | 2,988 | 57 | 70 | route |
| [15](session-15.md) | Leaf Nodes | 3,091 | 71 | 120 | leafnode |
| [16](session-16.md) | Gateways | 2,816 | 91 | 88 | gateway |
| [17](session-17.md) | Store Interfaces & Memory Store | 2,879 | 135 | 58 | store, memstore, disk_avail* |
| [18](session-18.md) | File Store | 11,421 | 312 | 249 | filestore |
| [19](session-19.md) | JetStream Core | 9,504 | 374 | 406 | jetstream, jetstream_api, jetstream_errors*, jetstream_events, jetstream_versioning, jetstream_batching |
| [20](session-20.md) | JetStream Cluster & Raft | 14,176 | 429 | 617 | raft, jetstream_cluster |
| [21](session-21.md) | Streams & Consumers | 12,700 | 402 | 315 | stream, consumer |
| [22](session-22.md) | MQTT | 4,758 | 153 | 162 | mqtt |
| [23](session-23.md) | WebSocket & OCSP | 2,962 | 97 | 113 | websocket, ocsp, ocsp_peer, ocsp_responsecache |
| | **Totals** | **~103K** | **3,394** | **3,137** | |
## Dependency Graph
```
S01 Foundation
├── S02 Utilities
├── S03 Options
├── S04 Logging
├── S05 Sublist ← S02
├── S06 Auth ← S03
└── S07 Parser
S08 Client ← S02, S03, S05, S07
S09 Server Init ← S03, S04, S05, S06
S10 Server Runtime ← S08, S09
S11 Accounts ← S02, S03, S05, S06
S12 Events & Monitor ← S08, S09, S11
S13 Reload ← S03, S09
S14 Routes ← S07, S08, S09
S15 Leafnodes ← S07, S08, S09, S14
S16 Gateways ← S07, S08, S09, S11, S14
S17 Store Interfaces ← S01, S02
S18 FileStore ← S17
S19 JetStream Core ← S08, S09, S11, S17
S20 JetStream Cluster ← S14, S17, S19
S21 Streams & Consumers ← S08, S09, S11, S17, S19
S22 MQTT ← S08, S09, S11, S17, S19
S23 WebSocket & OCSP ← S08, S09
```
## Multi-Sitting Sessions
Sessions 18, 19, 20, and 21 exceed the ~5K target and include sub-batching guidance in their individual files. Plan for 2-3 sittings each.
| Session | Go LOC | Recommended Sittings |
|---------|--------|---------------------|
| S18 File Store | 11,421 | 2-3 |
| S19 JetStream Core | 9,504 | 2-3 |
| S20 JetStream Cluster & Raft | 14,176 | 3-4 |
| S21 Streams & Consumers | 12,700 | 2-3 |
## Execution Order
Sessions should be executed roughly in order (S01 → S23), but parallel tracks are possible:
**Track A (Core):** S01 → S02 → S03 → S04 → S05 → S07 → S08 → S09 → S10
**Track B (Auth/Accounts):** S06 → S11 (after S03, S05)
**Track C (Networking):** S14 → S15 → S16 (after S08, S09)
**Track D (Storage):** S17 → S18 (after S01, S02)
**Track E (JetStream):** S19 → S20 → S21 (after S09, S11, S17)
**Track F (Protocols):** S22 → S23 (after S08, S09, S19)
**Cross-cutting:** S12, S13 (after S09, S11)
## How to Use
### Starting point
Begin with **Session 01** (Foundation Types). It has no dependencies and everything else builds on it.
### Session loop
Repeat until all 23 sessions are complete:
1. **Pick the next session.** Work through sessions in numerical order (S01 → S23). The numbering follows the dependency graph, so each session's prerequisites are already done by the time you reach it. If you want to parallelise, check the dependency graph above — any session whose dependencies are all complete is eligible.
2. **Open a new Claude Code session.** Reference the session file:
```
Port session N per docs/plans/phases/phase6sessions/session-NN.md
```
3. **Port features.** For each feature in the session:
- Mark as `stub` in `porting.db`
- Implement the .NET code referencing the Go source
- Mark as `complete` in `porting.db`
4. **Port tests.** For each test listed in the session file:
- Implement the xUnit test
- Run it: `dotnet test --filter "FullyQualifiedName~ClassName"`
- Mark as `complete` in `porting.db`
5. **Verify the build.** Run `dotnet build` and `dotnet test` to confirm nothing is broken.
6. **Commit.** Commit all changes with a message like `feat: port session NN — <session name>`.
7. **Check progress.**
```bash
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
```
### Multi-sitting sessions
Sessions 18, 19, 20, and 21 are too large for a single sitting. Each session file contains sub-batching guidance (e.g., 18a, 18b, 18c). Commit after each sub-batch rather than waiting for the entire session.
### Completion
All 23 sessions are done when:
- Every feature in module 8 is `complete` or `n/a`
- Every unit test in module 8 is `complete` or `n/a`
- `dotnet build` succeeds
- `dotnet test` passes

View File

@@ -0,0 +1,48 @@
# Session 01: Foundation Types
## Summary
Constants, error types, error catalog, protocol definitions, ring buffer, rate counter, stream distribution model, and NKey utilities. These are the leaf types with no internal dependencies — everything else builds on them.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/const.go | 2 | 582583 | 18 |
| server/errors.go | 15 | 833847 | 92 |
| server/errors_gen.go | 6 | 848853 | 158 |
| server/proto.go | 6 | 25932598 | 237 |
| server/ring.go | 6 | 28892894 | 34 |
| server/rate_counter.go | 3 | 27972799 | 34 |
| server/sdm.go | 5 | 29662970 | 39 |
| server/nkey.go | 3 | 24402442 | 14 |
| **Total** | **46** | | **626** |
## .NET Classes
- `Constants` — server constants and version info
- `ServerErrorCatalog` — generated error codes and messages
- `Protocol` — NATS protocol string constants
- `RingBuffer` — fixed-size circular buffer
- `RateCounter` — sliding window rate measurement
- `StreamDistributionModel` — stream distribution enum/types
- `NkeyUser` — NKey authentication types
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/errors_test.go | 2 | 297298 |
| server/ring_test.go | 2 | 27942795 |
| server/rate_counter_test.go | 1 | 2720 |
| server/nkey_test.go | 9 | 23622370 |
| server/trust_test.go | 3 | 30583060 |
| **Total** | **17** | |
## Dependencies
- None (leaf session)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/` — types and enums at root or `Internal/`

View File

@@ -0,0 +1,42 @@
# Session 02: Utilities & Queues
## Summary
General utility functions, IP-based queue, send queue, task scheduler, and subject transform engine. These are infrastructure pieces used across the server.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/util.go | 21 | 34853505 | 244 |
| server/ipqueue.go | 14 | 13541367 | 175 |
| server/sendq.go | 3 | 29712973 | 76 |
| server/scheduler.go | 14 | 29522965 | 260 |
| server/subject_transform.go | 16 | 33883403 | 570 |
| **Total** | **68** | | **1,325** |
## .NET Classes
- `ServerUtilities` — string/byte helpers, random, hashing
- `IpQueue<T>` — lock-free concurrent queue with IP grouping
- `SendQueue` — outbound message queue
- `Scheduler` — time-based task scheduler
- `SubjectTransform` — NATS subject rewriting/mapping engine
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/util_test.go | 13 | 30613073 |
| server/ipqueue_test.go | 28 | 688715 |
| server/subject_transform_test.go | 4 | 29582961 |
| server/split_test.go | 12 | 29292940 |
| **Total** | **57** | |
## Dependencies
- Session 01 (Foundation Types)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/Internal/`

View File

@@ -0,0 +1,37 @@
# Session 03: Configuration & Options
## Summary
The server options/configuration system. Parses config files, command-line args, and environment variables into the `ServerOptions` struct. This is large (5.4K LOC) but self-contained.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/opts.go | 86 | 25022587 | 5,400 |
| **Total** | **86** | | **5,400** |
## .NET Classes
- `ServerOptions` — all configuration properties, parsing, validation, and defaults
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/opts_test.go | 86 | 25122597 |
| server/config_check_test.go | 3 | 271273 |
| **Total** | **89** | |
## Dependencies
- Session 01 (Foundation Types — constants, errors)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs`
## Notes
- This is a large flat file. Consider splitting `ServerOptions` into partial classes by concern (TLS options, cluster options, JetStream options, etc.)
- Many options have default values defined in `const.go` (Session 01)

View File

@@ -0,0 +1,48 @@
# Session 04: Logging, Signals & Services
## Summary
Logging infrastructure, OS signal handling (Unix/Windows/WASM), and Windows service management. Small session — good opportunity to also address platform-specific abstractions.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/log.go | 18 | 20502067 | 207 |
| server/signal.go | 5 | 31553159 | 156 |
| server/signal_wasm.go | 2 | 31603161 | 6 |
| server/signal_windows.go | 2 | 31623163 | 79 |
| server/service.go | 2 | 31483149 | 7 |
| server/service_windows.go | 5 | 31503154 | 79 |
| **Total** | **34** | | **534** |
## .NET Classes
- `NatsLogger` (or logging integration) — server logging wrapper
- `SignalHandler` — OS signal handling (SIGTERM, SIGHUP, etc.)
- `ServiceManager` — Windows service lifecycle
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/log_test.go | 6 | 20172022 |
| server/signal_test.go | 19 | 29102928 |
| server/service_test.go | 1 | 2908 |
| server/service_windows_test.go | 1 | 2909 |
| **Total** | **27** | |
## Dependencies
- Session 01 (Foundation Types)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/Internal/` (logging)
- `dotnet/src/ZB.MOM.NatsNet.Server.Host/` (signal/service)
## Notes
- .NET uses `Microsoft.Extensions.Logging` + Serilog per standards
- Windows service support maps to `Microsoft.Extensions.Hosting.WindowsServices`
- Signal handling maps to `Console.CancelKeyPress` + `AppDomain.ProcessExit`

View File

@@ -0,0 +1,40 @@
# Session 05: Subscription Index
## Summary
The subscription list (sublist) — a trie-based data structure for matching NATS subjects to subscriptions. Core to message routing performance.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/sublist.go | 81 | 34043484 | 1,416 |
| **Total** | **81** | | **1,416** |
## .NET Classes
- `SubscriptionIndex` — trie-based subject matching
- `SubscriptionIndexResult` — match result container
- `SublistStats` — statistics for the subscription index
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/sublist_test.go | 96 | 29623057 |
| **Total** | **96** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 02 (Utilities — subject parsing helpers)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/Internal/DataStructures/SubscriptionIndex.cs`
## Notes
- Performance-critical: hot path for every message published
- Use `ReadOnlySpan<byte>` for subject matching on hot paths
- The existing `SubjectTree` (already ported in stree module) is different from this — sublist is the subscription matcher

View File

@@ -0,0 +1,45 @@
# Session 06: Authentication & JWT
## Summary
Authentication handlers (user/pass, token, NKey, TLS cert), auth callout (external auth service), JWT processing, and cipher suite definitions.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/auth.go | 31 | 350380 | 1,498 |
| server/auth_callout.go | 3 | 381383 | 456 |
| server/jwt.go | 6 | 19731978 | 205 |
| server/ciphersuites.go | 3 | 384386 | 37 |
| **Total** | **43** | | **2,196** |
## .NET Classes
- `AuthHandler` — authentication dispatch and credential checking
- `AuthCallout` — external auth callout service
- `JwtProcessor` — NATS JWT validation and claims extraction
- `CipherSuites` — TLS cipher suite definitions
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/auth_test.go | 12 | 142153 |
| server/auth_callout_test.go | 31 | 111141 |
| server/jwt_test.go | 88 | 18091896 |
| **Total** | **131** | |
## Dependencies
- Session 01 (Foundation Types — errors, constants)
- Session 03 (Configuration — ServerOptions for auth config)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/Auth/`
## Notes
- Auth is already partially scaffolded from leaf modules (certidp, certstore, tpm)
- JWT test file is large (88 tests) — may need careful batching within the session

View File

@@ -0,0 +1,39 @@
# Session 07: Protocol Parser
## Summary
The NATS protocol parser — parses raw bytes from client connections into protocol operations (PUB, SUB, UNSUB, CONNECT, etc.). Extremely performance-critical.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/parser.go | 5 | 25882592 | 1,165 |
| **Total** | **5** | | **1,165** |
## .NET Classes
- `ProtocolParser` — state-machine parser for NATS wire protocol
- `ClientConnection` (partial — parser-related methods only)
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/parser_test.go | 17 | 25982614 |
| **Total** | **17** | |
## Dependencies
- Session 01 (Foundation Types — protocol constants, errors)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/Protocol/`
## Notes
- Only 5 features but 1,165 LOC — these are large state-machine functions
- Must use `ReadOnlySpan<byte>` and avoid allocations in the parse loop
- The parser is called for every byte received — benchmark after porting
- Consider using `System.IO.Pipelines` for buffer management

View File

@@ -0,0 +1,49 @@
# Session 08: Client Connection
## Summary
The client connection handler — manages individual client TCP connections, message processing, subscription management, and client lifecycle. The largest single class in the server.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/client.go | 185 | 387571 | 5,680 |
| server/client_proxyproto.go | 10 | 572581 | 273 |
| **Total** | **195** | | **5,953** |
## .NET Classes
- `ClientConnection` — client state, read/write loops, publish, subscribe, unsubscribe
- `ClientFlag` — client state flags
- `ClientInfo` — client metadata
- `ProxyProtocolAddress` — PROXY protocol v1/v2 parsing
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/client_test.go | 82 | 182263 |
| server/client_proxyproto_test.go | 23 | 159181 |
| server/closed_conns_test.go | 7 | 264270 |
| server/ping_test.go | 1 | 2615 |
| **Total** | **113** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 02 (Utilities — queues)
- Session 03 (Configuration — ServerOptions)
- Session 05 (Subscription Index)
- Session 07 (Protocol Parser)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs`
## Notes
- This is the core networking class — every connected client has one
- Heavy use of `sync.Mutex` in Go → consider `lock` or `SemaphoreSlim`
- Write coalescing and flush logic is performance-critical
- May need partial class split: `ClientConnection.Read.cs`, `ClientConnection.Write.cs`, etc.

View File

@@ -0,0 +1,52 @@
# Session 09: Server Core — Initialization & Configuration
## Summary
First half of server.go: server construction, validation, account configuration, resolver setup, trusted keys, and the `Start()` method. This is the server bootstrap path.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/server.go (lines 852575) | ~76 | 29743050 | ~1,950 |
| **Total** | **~76** | | **~1,950** |
### Key Features
- `New`, `NewServer`, `NewServerFromConfig` — constructors
- `validateOptions`, `validateCluster`, `validatePinnedCerts` — config validation
- `configureAccounts`, `configureResolver`, `checkResolvePreloads` — account setup
- `processTrustedKeys`, `initStampedTrustedKeys` — JWT trust chain
- `Start` — main server startup (313 LOC)
- Compression helpers (`selectCompressionMode`, `s2WriterOptions`, etc.)
- Account lookup/register/update methods
## .NET Classes
- `NatsServer` (partial — initialization, configuration, accounts)
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/server_test.go (partial) | ~20 | 28662885 |
| **Total** | **~20** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 03 (Configuration — ServerOptions)
- Session 04 (Logging)
- Session 05 (Subscription Index)
- Session 06 (Authentication)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs` (partial class)
- Consider: `NatsServer.Init.cs`, `NatsServer.Accounts.cs`
## Notes
- `Server.Start()` is 313 LOC — the single largest function. Port carefully.
- Account configuration deeply intertwines with JWT and resolver subsystems
- Many methods reference route, gateway, and leafnode structures (forward declarations needed)

View File

@@ -0,0 +1,57 @@
# Session 10: Server Core — Runtime & Lifecycle
## Summary
Second half of server.go: accept loops, client creation, monitoring HTTP server, TLS handling, lame duck mode, shutdown, and runtime query methods.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/server.go (lines 25774782) | ~98 | 30513147 | ~1,881 |
| **Total** | **~98** | | **~1,881** |
### Key Features
- `Shutdown` — graceful shutdown (172 LOC)
- `AcceptLoop`, `acceptConnections` — TCP listener
- `createClientEx` — client connection factory (305 LOC)
- `startMonitoring`, `StartHTTPMonitoring` — HTTP monitoring server
- `lameDuckMode`, `sendLDMToRoutes`, `sendLDMToClients` — lame duck
- `readyForConnections`, `readyForListeners` — startup synchronization
- Numerous `Num*` query methods (routes, clients, subscriptions, etc.)
- `getConnectURLs`, `PortsInfo` — connection metadata
- `removeClient`, `saveClosedClient` — client lifecycle
## .NET Classes
- `NatsServer` (partial — runtime, lifecycle, queries)
- `CaptureHTTPServerLog` — HTTP log adapter
- `TlsMixConn` — mixed TLS/plain connection
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/server_test.go (partial) | ~22 | 28862907 |
| server/benchmark_publish_test.go | 1 | 154 |
| server/core_benchmarks_test.go | 4 | 274277 |
| **Total** | **~27** | |
## Dependencies
- Session 09 (Server Core Part 1)
- Session 08 (Client Connection)
- Session 04 (Logging)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs` (partial class)
- Consider: `NatsServer.Lifecycle.cs`, `NatsServer.Listeners.cs`
## Notes
- `createClientEx` is 305 LOC — second largest function in the file
- `Shutdown` involves coordinating across all subsystems
- Monitoring HTTP server maps to ASP.NET Core Kestrel or minimal API
- Lame duck mode requires careful timer/signal coordination

View File

@@ -0,0 +1,52 @@
# Session 11: Accounts & Directory Store
## Summary
Multi-tenancy account system and directory-based JWT store. Accounts manage per-tenant state including JetStream limits, imports/exports, and user authentication.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/accounts.go | 200 | 150349 | 3,918 |
| server/dirstore.go | 34 | 793826 | 575 |
| **Total** | **234** | | **4,493** |
## .NET Classes
- `Account` — per-tenant account with limits, imports, exports
- `DirectoryAccountResolver` — file-system-based account resolver
- `CacheDirAccountResolver` — caching resolver wrapper
- `MemoryAccountResolver` — in-memory resolver
- `UriAccountResolver` — HTTP-based resolver
- `DirJwtStore` — JWT file storage
- `DirectoryStore` — directory abstraction
- `ExpirationTracker` — JWT expiration tracking
- `LocalCache` — local account cache
- `ServiceExport`, `ServiceImport`, `ServiceLatency` — service mesh types
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/accounts_test.go | 65 | 46110 |
| server/dirstore_test.go | 19 | 278296 |
| **Total** | **84** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 02 (Utilities)
- Session 03 (Configuration)
- Session 05 (Subscription Index)
- Session 06 (Auth & JWT)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/Accounts/`
## Notes
- `Account` is the 4th largest class (4.5K LOC across multiple Go files)
- accounts.go alone has 200 features — will need methodical batching within the session
- Account methods are spread across accounts.go, consumer.go, events.go, jetstream.go, etc. — this session covers only accounts.go features

View File

@@ -0,0 +1,53 @@
# Session 12: Events, Monitoring & Message Tracing
## Summary
Server-side event system (system events, advisory messages), HTTP monitoring endpoints (varz, connz, routez, etc.), and message tracing infrastructure.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/events.go | 97 | 854950 | 2,445 |
| server/monitor.go | 70 | 21662235 | 3,257 |
| server/monitor_sort_opts.go | 16 | 22362251 | 48 |
| server/msgtrace.go | 35 | 24052439 | 569 |
| **Total** | **218** | | **6,319** |
## .NET Classes
- `EventsHandler` — system event publishing
- `MonitoringHandler` — HTTP monitoring endpoints
- `ConnInfo`, `ClosedState` — connection monitoring types
- `HealthZErrorType` — health check error types
- `MsgTrace`, `MsgTraceEvent`, `MsgTraceEvents` — message tracing
- `MessageTracer` — tracing engine
- Various sort option types (16 types)
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/events_test.go | 52 | 299350 |
| server/monitor_test.go | 103 | 20642166 |
| server/msgtrace_test.go | 33 | 23292361 |
| **Total** | **188** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 08 (Client Connection)
- Session 09 (Server Core Part 1)
- Session 11 (Accounts)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/Monitoring/`
- `dotnet/src/ZB.MOM.NatsNet.Server/Events/`
## Notes
- Monitor endpoints map to ASP.NET Core minimal API or controller endpoints
- Events system uses internal pub/sub — publishes to `$SYS.*` subjects
- This is a larger session (~6.3K LOC) but the code is relatively straightforward
- Monitor has 103 tests — allocate time accordingly

View File

@@ -0,0 +1,39 @@
# Session 13: Configuration Reload
## Summary
Hot-reload system for server configuration. Detects config changes and applies them without restarting the server. Each option type has a reload handler.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/reload.go | 89 | 28002888 | 2,085 |
| **Total** | **89** | | **2,085** |
## .NET Classes
- `ConfigReloader` — reload orchestrator
- 50+ individual option reload types (e.g., `AuthOption`, `TlsOption`, `ClusterOption`, `JetStreamOption`, etc.)
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/reload_test.go | 73 | 27212793 |
| **Total** | **73** | |
## Dependencies
- Session 03 (Configuration — ServerOptions)
- Session 09 (Server Core Part 1)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/ConfigReloader.cs`
## Notes
- Many small reload option types — consider using a single file with nested classes or a separate `Reload/` folder
- Each option type implements a common interface for diff/apply pattern
- 73 tests cover each option type's reload behavior

View File

@@ -0,0 +1,41 @@
# Session 14: Routes
## Summary
Inter-server routing — how NATS servers form a full mesh cluster and route messages between nodes.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/route.go | 57 | 28952951 | 2,988 |
| **Total** | **57** | | **2,988** |
## .NET Classes
- `RouteHandler` — route connection management
- `ClientConnection` (partial — route-specific methods, 25 features from client.go already counted in S08)
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/routes_test.go | 70 | 27962865 |
| **Total** | **70** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 07 (Protocol Parser)
- Session 08 (Client Connection)
- Session 09 (Server Core Part 1)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/Routing/`
## Notes
- Route connections are `ClientConnection` instances with special handling
- Protocol includes route-specific INFO, SUB, UNSUB, MSG operations
- Cluster gossip and route solicitation logic lives here

View File

@@ -0,0 +1,45 @@
# Session 15: Leaf Nodes
## Summary
Leaf node connections — lightweight connections from edge servers to hub servers. Simpler than full routes but with subject interest propagation.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/leafnode.go | 71 | 19792049 | 3,091 |
| **Total** | **71** | | **3,091** |
## .NET Classes
- `LeafNodeHandler` — leaf node connection management
- `LeafNodeCfg` — leaf node configuration
- `LeafNodeOption` — leaf node reload option
- `ClientConnection` (partial — leafnode-specific methods)
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/leafnode_test.go | 111 | 19062016 |
| server/leafnode_proxy_test.go | 9 | 18971905 |
| **Total** | **120** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 07 (Protocol Parser)
- Session 08 (Client Connection)
- Session 09 (Server Core Part 1)
- Session 14 (Routes — shared routing infrastructure)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/LeafNode/`
## Notes
- 111 + 9 = 120 tests — this is a test-heavy session
- Leaf nodes support TLS, auth, and subject deny lists
- WebSocket transport for leaf nodes adds complexity

View File

@@ -0,0 +1,47 @@
# Session 16: Gateways
## Summary
Gateway connections — inter-cluster message routing. Gateways enable NATS super-clusters where messages flow between independent clusters.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/gateway.go | 91 | 12631353 | 2,816 |
| **Total** | **91** | | **2,816** |
## .NET Classes
- `GatewayHandler` — gateway connection management
- `GatewayCfg` — gateway configuration
- `ServerGateway` — per-server gateway state
- `GatewayInterestMode` — interest/optimistic mode tracking
- `GwReplyMapping` — reply-to subject mapping for gateways
- `ClientConnection` (partial — gateway-specific methods)
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/gateway_test.go | 88 | 600687 |
| **Total** | **88** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 07 (Protocol Parser)
- Session 08 (Client Connection)
- Session 09 (Server Core Part 1)
- Session 11 (Accounts — for interest propagation)
- Session 14 (Routes)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/Gateway/`
## Notes
- Gateway protocol has optimistic and interest-only modes
- Account-aware interest propagation is complex
- 88 tests — thorough coverage of gateway scenarios

View File

@@ -0,0 +1,53 @@
# Session 17: Store Interfaces & Memory Store
## Summary
Storage abstraction layer (interfaces for streams and consumers) and the in-memory storage implementation. Also includes disk availability checks.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/store.go | 31 | 31643194 | 391 |
| server/memstore.go | 98 | 20682165 | 2,434 |
| server/disk_avail.go | 1 | 827 | 15 |
| server/disk_avail_netbsd.go | 1 | 828 | 3 |
| server/disk_avail_openbsd.go | 1 | 829 | 15 |
| server/disk_avail_solaris.go | 1 | 830 | 15 |
| server/disk_avail_wasm.go | 1 | 831 | 3 |
| server/disk_avail_windows.go | 1 | 832 | 3 |
| **Total** | **135** | | **2,879** |
## .NET Classes
- `StorageEngine` — storage interface definitions (`StreamStore`, `ConsumerStore`)
- `StoreMsg` — stored message type
- `StorageType`, `StoreCipher`, `StoreCompression` — storage enums
- `DeleteBlocks`, `DeleteRange`, `DeleteSlice` — deletion types
- `JetStreamMemoryStore` — in-memory stream store
- `ConsumerMemStore` — in-memory consumer store
- `DiskAvailability` — disk space checker (platform-specific)
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/store_test.go | 17 | 29412957 |
| server/memstore_test.go | 41 | 20232063 |
| **Total** | **58** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 02 (Utilities)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/Storage/`
## Notes
- Store interfaces define the contract for both memory and file stores
- MemStore is simpler than FileStore — good to port first as a reference implementation
- Disk availability uses platform-specific syscalls — map to `DriveInfo` in .NET
- Most disk_avail variants can be N/A (use .NET cross-platform API instead)

View File

@@ -0,0 +1,50 @@
# Session 18: File Store
## Summary
The persistent file-based storage engine for JetStream. Handles message persistence, compaction, encryption, compression, and recovery. This is the largest single-file session.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/filestore.go | 312 | 9511262 | 11,421 |
| **Total** | **312** | | **11,421** |
## .NET Classes
- `JetStreamFileStore` — file-based stream store (174 features, 7,255 LOC)
- `MessageBlock` — individual message block on disk (95 features, 3,314 LOC)
- `ConsumerFileStore` — file-based consumer store (33 features, 700 LOC)
- `CompressionInfo` — compression metadata
- `ErrBadMsg` — bad message error type
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/filestore_test.go | 249 | 351599 |
| **Total** | **249** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 02 (Utilities)
- Session 17 (Store Interfaces)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/Storage/`
## Notes
- **This is a multi-sitting session** — 11.4K Go LOC and 249 tests
- Suggested sub-batching:
- **18a**: `MessageBlock` (95 features, 3.3K LOC) — the on-disk block format
- **18b**: `JetStreamFileStore` core (load, store, recover, compact) — ~90 features
- **18c**: `JetStreamFileStore` remaining (snapshots, encryption, purge) — ~84 features
- **18d**: `ConsumerFileStore` (33 features, 700 LOC)
- **18e**: Tests (249 tests)
- File I/O should use `FileStream` with `RandomAccess` APIs for .NET 10
- Encryption maps to `System.Security.Cryptography`
- S2/Snappy compression maps to existing NuGet packages

View File

@@ -0,0 +1,67 @@
# Session 19: JetStream Core
## Summary
JetStream engine core — initialization, API handlers, error definitions, event types, versioning, and batching. The central JetStream coordination layer.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/jetstream.go | 84 | 13681451 | 2,481 |
| server/jetstream_api.go | 56 | 14521507 | 4,269 |
| server/jetstream_errors.go | 5 | 17511755 | 62 |
| server/jetstream_errors_generated.go | 203 | 17561958 | 1,924 |
| server/jetstream_events.go | 1 | 1959 | 25 |
| server/jetstream_versioning.go | 13 | 19601972 | 175 |
| server/jetstream_batching.go | 12 | 15081519 | 568 |
| **Total** | **374** | | **9,504** |
## .NET Classes
- `JetStreamEngine` — JetStream lifecycle, enable/disable, account tracking
- `JetStreamApi` — REST-like API handlers for stream/consumer CRUD
- `JetStreamErrors` — error code registry (208 entries)
- `JetStreamEvents` — advisory event types
- `JetStreamVersioning` — feature version compatibility
- `JetStreamBatching` — batch message processing
- `JsAccount` — per-account JetStream state
- `JsOutQ` — JetStream output queue
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/jetstream_test.go | 320 | 14661785 |
| server/jetstream_errors_test.go | 4 | 13811384 |
| server/jetstream_versioning_test.go | 18 | 17911808 |
| server/jetstream_batching_test.go | 29 | 716744 |
| server/jetstream_jwt_test.go | 18 | 13851402 |
| server/jetstream_tpm_test.go | 5 | 17861790 |
| server/jetstream_benchmark_test.go | 12 | 745756 |
| **Total** | **406** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 03 (Configuration)
- Session 08 (Client Connection)
- Session 09 (Server Core Part 1)
- Session 11 (Accounts)
- Session 17 (Store Interfaces)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/`
## Notes
- **This is a multi-sitting session** — 9.5K Go LOC and 406 tests
- JetStream errors generated file is 203 features but mostly boilerplate error codes
- jetstream_test.go has 320 tests — the largest test file
- Suggested sub-batching:
- **19a**: Error definitions and events (209 features, 2K LOC) — mostly mechanical
- **19b**: JetStream engine core (84 features, 2.5K LOC)
- **19c**: JetStream API (56 features, 4.3K LOC)
- **19d**: Versioning + batching (25 features, 743 LOC)
- **19e**: Tests (406 tests, batched by test file)

View File

@@ -0,0 +1,70 @@
# Session 20: JetStream Cluster & Raft
## Summary
Raft consensus algorithm implementation and JetStream clustering — how streams and consumers are replicated across server nodes.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/raft.go | 198 | 25992796 | 4,078 |
| server/jetstream_cluster.go | 231 | 15201750 | 10,098 |
| **Total** | **429** | | **14,176** |
## .NET Classes
- `RaftNode` — Raft consensus implementation (169 features)
- `AppendEntry`, `AppendEntryResponse` — Raft log entries
- `Checkpoint` — Raft snapshots
- `CommittedEntry`, `Entry`, `EntryType` — entry types
- `VoteRequest`, `VoteResponse`, `RaftState` — election types
- `RaftGroup` — Raft group configuration
- `JetStreamCluster` — cluster-wide JetStream coordination (51 features)
- `Consumer` (cluster) — consumer assignment tracking (7 features)
- `ConsumerAssignment` — consumer placement
- `StreamAssignment`, `UnsupportedStreamAssignment` — stream placement
- Plus ~69 `JetStreamEngine` methods for cluster operations
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/raft_test.go | 104 | 26162719 |
| server/jetstream_cluster_1_test.go | 151 | 757907 |
| server/jetstream_cluster_2_test.go | 123 | 9081030 |
| server/jetstream_cluster_3_test.go | 97 | 10311127 |
| server/jetstream_cluster_4_test.go | 85 | 11281212 |
| server/jetstream_cluster_long_test.go | 7 | 12131219 |
| server/jetstream_super_cluster_test.go | 47 | 14191465 |
| server/jetstream_meta_benchmark_test.go | 2 | 14161417 |
| server/jetstream_sourcing_scaling_test.go | 1 | 1418 |
| **Total** | **617** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 08 (Client Connection)
- Session 09 (Server Core Part 1)
- Session 14 (Routes)
- Session 17 (Store Interfaces)
- Session 19 (JetStream Core)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/Cluster/`
- `dotnet/src/ZB.MOM.NatsNet.Server/Raft/`
## Notes
- **This is a multi-sitting session** — 14.2K Go LOC and 617 tests (the largest session)
- Suggested sub-batching:
- **20a**: Raft types and election (entries, votes, state — ~30 features)
- **20b**: Raft core (log replication, append, commit — ~85 features)
- **20c**: Raft remaining (snapshots, checkpoints, recovery — ~83 features)
- **20d**: JetStream cluster types and assignments (~30 features)
- **20e**: JetStream cluster operations Part 1 (~130 features)
- **20f**: JetStream cluster operations Part 2 (~71 features)
- **20g**: Tests (617 tests, batched by test file)
- Raft is the most algorithmically complex code in the server
- Cluster tests often require multi-server setups — integration test candidates

View File

@@ -0,0 +1,60 @@
# Session 21: Streams & Consumers
## Summary
Stream and consumer implementations — the core JetStream data plane. Streams store messages; consumers track delivery state and manage acknowledgments.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/stream.go | 193 | 31953387 | 6,980 |
| server/consumer.go | 209 | 584792 | 5,720 |
| **Total** | **402** | | **12,700** |
## .NET Classes
- `NatsStream` — stream lifecycle, message ingestion, purge, snapshots (193 features)
- `NatsConsumer` — consumer lifecycle, delivery, ack, nak, redelivery (174 features)
- `ConsumerAction`, `ConsumerConfig`, `AckPolicy`, `DeliverPolicy`, `ReplayPolicy` — consumer types
- `StreamConfig`, `StreamSource`, `ExternalStream` — stream types
- `PriorityPolicy`, `RetentionPolicy`, `DiscardPolicy`, `PersistModeType` — policy enums
- `WaitQueue`, `WaitingRequest`, `WaitingDelivery` — consumer wait types
- `JSPubAckResponse`, `PubMsg`, `JsPubMsg`, `InMsg`, `CMsg` — message types
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/jetstream_consumer_test.go | 161 | 12201380 |
| server/jetstream_leafnode_test.go | 13 | 14031415 |
| server/norace_1_test.go | 100 | 23712470 |
| server/norace_2_test.go | 41 | 24712511 |
| **Total** | **315** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 02 (Utilities)
- Session 08 (Client Connection)
- Session 09 (Server Core Part 1)
- Session 11 (Accounts)
- Session 17 (Store Interfaces)
- Session 19 (JetStream Core)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/`
## Notes
- **This is a multi-sitting session** — 12.7K Go LOC and 315 tests
- Suggested sub-batching:
- **21a**: Stream/consumer types and enums (~40 features, ~500 LOC)
- **21b**: NatsStream core (create, delete, purge — ~95 features)
- **21c**: NatsStream remaining (snapshots, sources, mirrors — ~98 features)
- **21d**: NatsConsumer core (create, deliver, ack — ~90 features)
- **21e**: NatsConsumer remaining (redelivery, pull, push — ~84 features)
- **21f**: Tests (315 tests)
- `norace_*_test.go` files contain tests that must run without the Go race detector — these may have concurrency timing sensitivities
- Consumer pull/push patterns need careful async design in C#

View File

@@ -0,0 +1,51 @@
# Session 22: MQTT
## Summary
MQTT 3.1.1/5.0 protocol adapter — allows MQTT clients to connect to NATS and interact with JetStream for persistence.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/mqtt.go | 153 | 22522404 | 4,758 |
| **Total** | **153** | | **4,758** |
## .NET Classes
- `MqttHandler` — MQTT protocol handler (35 features)
- `MqttAccountSessionManager` — per-account MQTT session tracking (26 features)
- `MqttSession` — individual MQTT session state (15 features)
- `MqttJetStreamAdapter` — bridges MQTT to JetStream (22 features)
- `MqttReader` — MQTT packet reader (8 features)
- `MqttWriter` — MQTT packet writer (5 features)
- Various MQTT reload options
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/mqtt_test.go | 159 | 21702328 |
| server/mqtt_ex_test_test.go | 2 | 21682169 |
| server/mqtt_ex_bench_test.go | 1 | 2167 |
| **Total** | **162** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 08 (Client Connection)
- Session 09 (Server Core Part 1)
- Session 11 (Accounts)
- Session 17 (Store Interfaces)
- Session 19 (JetStream Core)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/`
## Notes
- MQTT is a self-contained protocol layer — could potentially be a separate assembly
- 159 MQTT tests cover connection, subscribe, publish, QoS levels, sessions, retained messages
- MQTT ↔ JetStream bridging is the most complex part
- Consider using `System.IO.Pipelines` for MQTT packet parsing

View File

@@ -0,0 +1,52 @@
# Session 23: WebSocket & OCSP
## Summary
WebSocket transport layer (allows browser clients to connect via WebSocket) and OCSP certificate stapling/checking infrastructure.
## Scope
| Go File | Features | Feature IDs | Go LOC |
|---------|----------|-------------|--------|
| server/websocket.go | 38 | 35063543 | 1,265 |
| server/ocsp.go | 20 | 24432462 | 880 |
| server/ocsp_peer.go | 9 | 24632471 | 356 |
| server/ocsp_responsecache.go | 30 | 24722501 | 461 |
| **Total** | **97** | | **2,962** |
## .NET Classes
- `WebSocketHandler` — WebSocket upgrade and frame handling
- `WsReadInfo` — WebSocket read state
- `SrvWebsocket` — WebSocket server configuration
- `OcspHandler` — OCSP stapling orchestrator
- `OCSPMonitor` — background OCSP response refresher
- `NoOpCache` — no-op OCSP cache implementation
## Test Files
| Test File | Tests | Test IDs |
|-----------|-------|----------|
| server/websocket_test.go | 109 | 30743182 |
| server/certstore_windows_test.go | 4 | 155158 |
| **Total** | **113** | |
## Dependencies
- Session 01 (Foundation Types)
- Session 08 (Client Connection)
- Session 09 (Server Core Part 1)
- Leaf module: certidp (already complete)
- Leaf module: certstore (already complete)
## .NET Target Location
- `dotnet/src/ZB.MOM.NatsNet.Server/WebSocket/`
- `dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/`
## Notes
- WebSocket maps to ASP.NET Core WebSocket middleware or `System.Net.WebSockets`
- OCSP integrates with the already-ported certidp and certstore modules
- WebSocket test file has 109 tests — covers masking, framing, compression, upgrade
- OCSP response cache has 30 features — manage certificate stapling lifecycle

View File

@@ -0,0 +1,10 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ZB.MOM.NatsNet.Server.Host/ZB.MOM.NatsNet.Server.Host.csproj" />
<Project Path="src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.NatsNet.Server.IntegrationTests/ZB.MOM.NatsNet.Server.IntegrationTests.csproj" />
<Project Path="tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj" />
</Folder>
</Solution>

BIN
dotnet/porting.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,2 @@
// Entry point placeholder - will be populated during server module porting
Console.WriteLine("ZB.MOM.NatsNet.Server");

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="*" />
<PackageReference Include="Serilog" Version="*" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="*" />
<PackageReference Include="Serilog.Sinks.Console" Version="*" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,273 @@
// 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/auth.go (standalone functions) in the NATS server Go source.
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace ZB.MOM.NatsNet.Server.Auth;
/// <summary>
/// Authentication helper methods ported from Go auth.go.
/// Server-dependent methods (configureAuthorization, checkAuthentication, etc.)
/// will be added in later sessions when the full Server type is available.
/// </summary>
public static partial class AuthHandler
{
/// <summary>
/// Regex matching valid bcrypt password prefixes ($2a$, $2b$, $2x$, $2y$).
/// Mirrors Go <c>validBcryptPrefix</c>.
/// </summary>
private static readonly Regex ValidBcryptPrefix = ValidBcryptPrefixRegex();
[GeneratedRegex(@"^\$2[abxy]\$\d{2}\$.*")]
private static partial Regex ValidBcryptPrefixRegex();
/// <summary>
/// Checks if a password string is a bcrypt hash.
/// Mirrors Go <c>isBcrypt</c>.
/// </summary>
public static bool IsBcrypt(string password)
{
if (password.StartsWith('$'))
{
return ValidBcryptPrefix.IsMatch(password);
}
return false;
}
/// <summary>
/// Compares a server password (possibly bcrypt-hashed) against a client-provided password.
/// Uses constant-time comparison for plaintext passwords.
/// Mirrors Go <c>comparePasswords</c>.
/// </summary>
public static bool ComparePasswords(string serverPassword, string clientPassword)
{
if (IsBcrypt(serverPassword))
{
return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword);
}
// Constant-time comparison for plaintext passwords.
var spass = Encoding.UTF8.GetBytes(serverPassword);
var cpass = Encoding.UTF8.GetBytes(clientPassword);
return CryptographicOperations.FixedTimeEquals(spass, cpass);
}
/// <summary>
/// Validates the ResponsePermission defaults within a Permissions struct.
/// If Response is set but MaxMsgs/Expires are zero, applies defaults.
/// Also ensures Publish is set with an empty Allow if not already defined.
/// Mirrors Go <c>validateResponsePermissions</c>.
/// </summary>
public static void ValidateResponsePermissions(Permissions? p)
{
if (p?.Response == null)
{
return;
}
p.Publish ??= new SubjectPermission();
p.Publish.Allow ??= [];
if (p.Response.MaxMsgs == 0)
{
p.Response.MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs;
}
if (p.Response.Expires == TimeSpan.Zero)
{
p.Response.Expires = ServerConstants.DefaultAllowResponseExpiration;
}
}
/// <summary>
/// Known connection type strings (uppercased).
/// Mirrors Go jwt.ConnectionType* constants.
/// </summary>
public static class ConnectionTypes
{
public const string Standard = "STANDARD";
public const string Websocket = "WEBSOCKET";
public const string Leafnode = "LEAFNODE";
public const string LeafnodeWs = "LEAFNODE_WS";
public const string Mqtt = "MQTT";
public const string MqttWs = "MQTT_WS";
public const string InProcess = "IN_PROCESS";
private static readonly HashSet<string> Known =
[
Standard,
Websocket,
Leafnode,
LeafnodeWs,
Mqtt,
MqttWs,
InProcess,
];
public static bool IsKnown(string ct) => Known.Contains(ct);
}
/// <summary>
/// Validates allowed connection type map entries. Normalises to uppercase
/// and rejects unknown types.
/// Mirrors Go <c>validateAllowedConnectionTypes</c>.
/// </summary>
public static Exception? ValidateAllowedConnectionTypes(HashSet<string>? m)
{
if (m == null) return null;
// We must iterate a copy since we may modify the set.
var entries = m.ToList();
foreach (var ct in entries)
{
var ctuc = ct.ToUpperInvariant();
if (!ConnectionTypes.IsKnown(ctuc))
{
return new ArgumentException($"unknown connection type \"{ct}\"");
}
if (ctuc != ct)
{
m.Remove(ct);
m.Add(ctuc);
}
}
return null;
}
/// <summary>
/// Validates the no_auth_user setting against configured users/nkeys.
/// Mirrors Go <c>validateNoAuthUser</c>.
/// </summary>
public static Exception? ValidateNoAuthUser(ServerOptions o, string noAuthUser)
{
if (string.IsNullOrEmpty(noAuthUser))
{
return null;
}
if (o.TrustedOperators.Count > 0)
{
return new InvalidOperationException("no_auth_user not compatible with Trusted Operator");
}
if (o.Nkeys == null && o.Users == null)
{
return new InvalidOperationException(
$"no_auth_user: \"{noAuthUser}\" present, but users/nkeys are not defined");
}
if (o.Users != null)
{
foreach (var u in o.Users)
{
if (u.Username == noAuthUser) return null;
}
}
if (o.Nkeys != null)
{
foreach (var u in o.Nkeys)
{
if (u.Nkey == noAuthUser) return null;
}
}
return new InvalidOperationException(
$"no_auth_user: \"{noAuthUser}\" not present as user or nkey in authorization block or account configuration");
}
/// <summary>
/// Validates the auth section of options: pinned certs, connection types, and no_auth_user.
/// Mirrors Go <c>validateAuth</c>.
/// </summary>
public static Exception? ValidateAuth(ServerOptions o)
{
// validatePinnedCerts will be added when the full server module is ported.
if (o.Users != null)
{
foreach (var u in o.Users)
{
var err = ValidateAllowedConnectionTypes(u.AllowedConnectionTypes);
if (err != null) return err;
}
}
if (o.Nkeys != null)
{
foreach (var u in o.Nkeys)
{
var err = ValidateAllowedConnectionTypes(u.AllowedConnectionTypes);
if (err != null) return err;
}
}
return ValidateNoAuthUser(o, o.NoAuthUser);
}
/// <summary>
/// Splits a DNS alt name into lowercase labels.
/// Mirrors Go <c>dnsAltNameLabels</c>.
/// </summary>
public static string[] DnsAltNameLabels(string dnsAltName)
{
return dnsAltName.ToLowerInvariant().Split('.');
}
/// <summary>
/// Checks if DNS alt name labels match any of the provided URLs (RFC 6125).
/// The wildcard '*' only matches the leftmost label.
/// Mirrors Go <c>dnsAltNameMatches</c>.
/// </summary>
public static bool DnsAltNameMatches(string[] dnsAltNameLabels, IReadOnlyList<Uri?> urls)
{
foreach (var url in urls)
{
if (url == null)
{
continue;
}
var hostLabels = url.Host.ToLowerInvariant().Split('.');
// Following RFC 6125: wildcard never matches multiple labels, only leftmost.
if (hostLabels.Length != dnsAltNameLabels.Length)
{
continue;
}
var i = 0;
// Only match wildcard on leftmost label.
if (dnsAltNameLabels[0] == "*")
{
i++;
}
var matched = true;
for (; i < dnsAltNameLabels.Length; i++)
{
if (dnsAltNameLabels[i] != hostLabels[i])
{
matched = false;
break;
}
}
if (matched) return true;
}
return false;
}
/// <summary>
/// Wipes a byte slice by filling with 'x'. Used for clearing sensitive data.
/// Mirrors Go <c>wipeSlice</c>.
/// </summary>
public static void WipeSlice(Span<byte> buf)
{
buf.Fill((byte)'x');
}
}

View File

@@ -0,0 +1,176 @@
// 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/auth.go (type definitions) in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Auth;
/// <summary>
/// Represents a user authenticated via NKey.
/// Mirrors Go <c>NkeyUser</c> struct in auth.go.
/// </summary>
public class NkeyUser
{
public string Nkey { get; set; } = string.Empty;
public long Issued { get; set; }
public Permissions? Permissions { get; set; }
public Account? Account { get; set; }
public string SigningKey { get; set; } = string.Empty;
public HashSet<string>? AllowedConnectionTypes { get; set; }
public bool ProxyRequired { get; set; }
/// <summary>
/// Deep-clones this NkeyUser. Account is shared by reference.
/// Mirrors Go <c>NkeyUser.clone()</c>.
/// </summary>
public NkeyUser? Clone()
{
var clone = (NkeyUser)MemberwiseClone();
// Account is not cloned because it is always by reference to an existing struct.
clone.Permissions = Permissions?.Clone();
if (AllowedConnectionTypes != null)
{
clone.AllowedConnectionTypes = new HashSet<string>(AllowedConnectionTypes);
}
return clone;
}
}
/// <summary>
/// Represents a user with username/password credentials.
/// Mirrors Go <c>User</c> struct in auth.go.
/// </summary>
public class User
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public Permissions? Permissions { get; set; }
public Account? Account { get; set; }
public DateTime ConnectionDeadline { get; set; }
public HashSet<string>? AllowedConnectionTypes { get; set; }
public bool ProxyRequired { get; set; }
/// <summary>
/// Deep-clones this User. Account is shared by reference.
/// Mirrors Go <c>User.clone()</c>.
/// </summary>
public User? Clone()
{
var clone = (User)MemberwiseClone();
// Account is not cloned because it is always by reference to an existing struct.
clone.Permissions = Permissions?.Clone();
if (AllowedConnectionTypes != null)
{
clone.AllowedConnectionTypes = new HashSet<string>(AllowedConnectionTypes);
}
return clone;
}
}
/// <summary>
/// Subject-level allow/deny permission.
/// Mirrors Go <c>SubjectPermission</c> in auth.go.
/// </summary>
public class SubjectPermission
{
public List<string>? Allow { get; set; }
public List<string>? Deny { get; set; }
/// <summary>
/// Deep-clones this SubjectPermission.
/// Mirrors Go <c>SubjectPermission.clone()</c>.
/// </summary>
public SubjectPermission Clone()
{
var clone = new SubjectPermission();
if (Allow != null)
{
clone.Allow = new List<string>(Allow);
}
if (Deny != null)
{
clone.Deny = new List<string>(Deny);
}
return clone;
}
}
/// <summary>
/// Response permission for request-reply patterns.
/// Mirrors Go <c>ResponsePermission</c> in auth.go.
/// </summary>
public class ResponsePermission
{
public int MaxMsgs { get; set; }
public TimeSpan Expires { get; set; }
}
/// <summary>
/// Publish/subscribe permissions container.
/// Mirrors Go <c>Permissions</c> in auth.go.
/// </summary>
public class Permissions
{
public SubjectPermission? Publish { get; set; }
public SubjectPermission? Subscribe { get; set; }
public ResponsePermission? Response { get; set; }
/// <summary>
/// Deep-clones this Permissions struct.
/// Mirrors Go <c>Permissions.clone()</c>.
/// </summary>
public Permissions Clone()
{
var clone = new Permissions();
if (Publish != null)
{
clone.Publish = Publish.Clone();
}
if (Subscribe != null)
{
clone.Subscribe = Subscribe.Clone();
}
if (Response != null)
{
clone.Response = new ResponsePermission
{
MaxMsgs = Response.MaxMsgs,
Expires = Response.Expires,
};
}
return clone;
}
}
/// <summary>
/// Route-level import/export permissions.
/// Mirrors Go <c>RoutePermissions</c> in auth.go.
/// </summary>
public class RoutePermissions
{
public SubjectPermission? Import { get; set; }
public SubjectPermission? Export { get; set; }
}
/// <summary>
/// Stub for Account type. Full implementation in later sessions.
/// Mirrors Go <c>Account</c> struct.
/// </summary>
public class Account
{
public string Name { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,57 @@
namespace ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
/// <summary>
/// Error and debug message constants for the OCSP peer identity provider.
/// Mirrors certidp/messages.go.
/// </summary>
public static class OcspMessages
{
// Returned errors
public const string ErrIllegalPeerOptsConfig = "expected map to define OCSP peer options, got [{0}]";
public const string ErrIllegalCacheOptsConfig = "expected map to define OCSP peer cache options, got [{0}]";
public const string ErrParsingPeerOptFieldGeneric = "error parsing tls peer config, unknown field [\"{0}\"]";
public const string ErrParsingPeerOptFieldTypeConversion = "error parsing tls peer config, conversion error: {0}";
public const string ErrParsingCacheOptFieldTypeConversion = "error parsing OCSP peer cache config, conversion error: {0}";
public const string ErrUnableToPlugTLSEmptyConfig = "unable to plug TLS verify connection, config is nil";
public const string ErrMTLSRequired = "OCSP peer verification for client connections requires TLS verify (mTLS) to be enabled";
public const string ErrUnableToPlugTLSClient = "unable to register client OCSP verification";
public const string ErrUnableToPlugTLSServer = "unable to register server OCSP verification";
public const string ErrCannotWriteCompressed = "error writing to compression writer: {0}";
public const string ErrCannotReadCompressed = "error reading compression reader: {0}";
public const string ErrTruncatedWrite = "short write on body ({0} != {1})";
public const string ErrCannotCloseWriter = "error closing compression writer: {0}";
public const string ErrParsingCacheOptFieldGeneric = "error parsing OCSP peer cache config, unknown field [\"{0}\"]";
public const string ErrUnknownCacheType = "error parsing OCSP peer cache config, unknown type [{0}]";
public const string ErrInvalidChainlink = "invalid chain link";
public const string ErrBadResponderHTTPStatus = "bad OCSP responder http status: [{0}]";
public const string ErrNoAvailOCSPServers = "no available OCSP servers";
public const string ErrFailedWithAllRequests = "exhausted OCSP responders: {0}";
// Direct logged errors
public const string ErrLoadCacheFail = "Unable to load OCSP peer cache: {0}";
public const string ErrSaveCacheFail = "Unable to save OCSP peer cache: {0}";
public const string ErrBadCacheTypeConfig = "Unimplemented OCSP peer cache type [{0}]";
public const string ErrResponseCompressFail = "Unable to compress OCSP response for key [{0}]: {1}";
public const string ErrResponseDecompressFail = "Unable to decompress OCSP response for key [{0}]: {1}";
public const string ErrPeerEmptyNoEvent = "Peer certificate is nil, cannot send OCSP peer reject event";
public const string ErrPeerEmptyAutoReject = "Peer certificate is nil, rejecting OCSP peer";
// Debug messages
public const string DbgPlugTLSForKind = "Plugging TLS OCSP peer for [{0}]";
public const string DbgNumServerChains = "Peer OCSP enabled: {0} TLS server chain(s) will be evaluated";
public const string DbgNumClientChains = "Peer OCSP enabled: {0} TLS client chain(s) will be evaluated";
public const string DbgLinksInChain = "Chain [{0}]: {1} total link(s)";
public const string DbgSelfSignedValid = "Chain [{0}] is self-signed, thus peer is valid";
public const string DbgValidNonOCSPChain = "Chain [{0}] has no OCSP eligible links, thus peer is valid";
public const string DbgChainIsOCSPEligible = "Chain [{0}] has {1} OCSP eligible link(s)";
public const string DbgChainIsOCSPValid = "Chain [{0}] is OCSP valid for all eligible links, thus peer is valid";
public const string DbgNoOCSPValidChains = "No OCSP valid chains, thus peer is invalid";
public const string DbgCheckingCacheForCert = "Checking OCSP peer cache for [{0}], key [{1}]";
public const string DbgCurrentResponseCached = "Cached OCSP response is current, status [{0}]";
public const string DbgExpiredResponseCached = "Cached OCSP response is expired, status [{0}]";
public const string DbgOCSPValidPeerLink = "OCSP verify pass for [{0}]";
public const string DbgMakingCARequest = "Making OCSP CA request to [{0}]";
public const string DbgResponseExpired = "OCSP response expired: NextUpdate={0}, now={1}, skew={2}";
public const string DbgResponseTTLExpired = "OCSP response TTL expired: expiry={0}, now={1}, skew={2}";
public const string DbgResponseFutureDated = "OCSP response is future-dated: ThisUpdate={0}, now={1}, skew={2}";
}

View File

@@ -0,0 +1,129 @@
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
/// <summary>OCSP certificate status values.</summary>
/// <remarks>Mirrors the Go <c>ocsp.Good/Revoked/Unknown</c> constants (0/1/2).</remarks>
[JsonConverter(typeof(OcspStatusAssertionJsonConverter))]
public enum OcspStatusAssertion
{
Good = 0,
Revoked = 1,
Unknown = 2,
}
/// <summary>JSON converter: serializes <see cref="OcspStatusAssertion"/> as lowercase string.</summary>
public sealed class OcspStatusAssertionJsonConverter : JsonConverter<OcspStatusAssertion>
{
private static readonly IReadOnlyDictionary<string, OcspStatusAssertion> StrToVal =
new Dictionary<string, OcspStatusAssertion>(StringComparer.OrdinalIgnoreCase)
{
["good"] = OcspStatusAssertion.Good,
["revoked"] = OcspStatusAssertion.Revoked,
["unknown"] = OcspStatusAssertion.Unknown,
};
private static readonly IReadOnlyDictionary<OcspStatusAssertion, string> ValToStr =
new Dictionary<OcspStatusAssertion, string>
{
[OcspStatusAssertion.Good] = "good",
[OcspStatusAssertion.Revoked] = "revoked",
[OcspStatusAssertion.Unknown] = "unknown",
};
public override OcspStatusAssertion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var s = reader.GetString() ?? string.Empty;
return StrToVal.TryGetValue(s, out var v) ? v : OcspStatusAssertion.Unknown;
}
public override void Write(Utf8JsonWriter writer, OcspStatusAssertion value, JsonSerializerOptions options)
{
writer.WriteStringValue(ValToStr.TryGetValue(value, out var s) ? s : "unknown");
}
}
/// <summary>
/// Returns the string representation of an OCSP status integer.
/// Falls back to "unknown" for unrecognized values (never defaults to "good").
/// </summary>
public static class OcspStatusAssertionExtensions
{
public static string GetStatusAssertionStr(int statusInt) => statusInt switch
{
0 => "good",
1 => "revoked",
_ => "unknown",
};
}
/// <summary>Parsed OCSP peer configuration.</summary>
public sealed class OcspPeerConfig
{
public static readonly TimeSpan DefaultAllowedClockSkew = TimeSpan.FromSeconds(30);
public static readonly TimeSpan DefaultOCSPResponderTimeout = TimeSpan.FromSeconds(2);
public static readonly TimeSpan DefaultTTLUnsetNextUpdate = TimeSpan.FromHours(1);
public bool Verify { get; set; } = false;
public double Timeout { get; set; } = DefaultOCSPResponderTimeout.TotalSeconds;
public double ClockSkew { get; set; } = DefaultAllowedClockSkew.TotalSeconds;
public bool WarnOnly { get; set; } = false;
public bool UnknownIsGood { get; set; } = false;
public bool AllowWhenCAUnreachable { get; set; } = false;
public double TTLUnsetNextUpdate { get; set; } = DefaultTTLUnsetNextUpdate.TotalSeconds;
/// <summary>Returns a new <see cref="OcspPeerConfig"/> with defaults populated.</summary>
public static OcspPeerConfig Create() => new();
}
/// <summary>
/// Represents a certificate chain link: a leaf certificate and its issuer,
/// plus the OCSP web endpoints parsed from the leaf's AIA extension.
/// </summary>
public sealed class ChainLink
{
public X509Certificate2? Leaf { get; set; }
public X509Certificate2? Issuer { get; set; }
public IReadOnlyList<Uri>? OcspWebEndpoints { get; set; }
}
/// <summary>
/// Parsed OCSP response data. Mirrors the fields of <c>golang.org/x/crypto/ocsp.Response</c>
/// needed by <see cref="OcspUtilities"/>.
/// </summary>
/// <remarks>
/// Full OCSP response parsing (DER/ASN.1) requires an additional library (e.g. Bouncy Castle).
/// This type represents the already-parsed response for use in validation and caching logic.
/// </remarks>
public sealed class OcspResponse
{
public OcspStatusAssertion Status { get; init; }
public DateTime ThisUpdate { get; init; }
/// <summary><see cref="DateTime.MinValue"/> means "not set" (CA did not supply NextUpdate).</summary>
public DateTime NextUpdate { get; init; }
/// <summary>Optional delegated signer certificate (RFC 6960 §4.2.2.2).</summary>
public X509Certificate2? Certificate { get; init; }
}
/// <summary>Neutral logging interface for plugin use. Mirrors the Go <c>certidp.Log</c> struct.</summary>
public sealed class OcspLog
{
public Action<string, object[]>? Debugf { get; set; }
public Action<string, object[]>? Noticef { get; set; }
public Action<string, object[]>? Warnf { get; set; }
public Action<string, object[]>? Errorf { get; set; }
public Action<string, object[]>? Tracef { get; set; }
internal void Debug(string format, params object[] args) => Debugf?.Invoke(format, args);
}
/// <summary>JSON-serializable certificate information.</summary>
public sealed class CertInfo
{
[JsonPropertyName("subject")] public string? Subject { get; init; }
[JsonPropertyName("issuer")] public string? Issuer { get; init; }
[JsonPropertyName("fingerprint")] public string? Fingerprint { get; init; }
[JsonPropertyName("raw")] public byte[]? Raw { get; init; }
}

View File

@@ -0,0 +1,73 @@
using System.Net.Http;
namespace ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
/// <summary>
/// OCSP responder communication: fetches raw OCSP response bytes from CA endpoints.
/// Mirrors certidp/ocsp_responder.go.
/// </summary>
public static class OcspResponder
{
/// <summary>
/// Fetches an OCSP response from the responder URLs in <paramref name="link"/>.
/// Tries each endpoint in order and returns the first successful response.
/// </summary>
/// <param name="link">Chain link containing leaf cert, issuer cert, and OCSP endpoints.</param>
/// <param name="opts">Configuration (timeout, etc.).</param>
/// <param name="log">Optional logger.</param>
/// <param name="ocspRequest">DER-encoded OCSP request bytes to send.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Raw DER bytes of the OCSP response.</returns>
public static async Task<byte[]> FetchOCSPResponseAsync(
ChainLink link,
OcspPeerConfig opts,
byte[] ocspRequest,
OcspLog? log = null,
CancellationToken cancellationToken = default)
{
if (link.Leaf is null || link.Issuer is null)
throw new ArgumentException(OcspMessages.ErrInvalidChainlink, nameof(link));
if (link.OcspWebEndpoints is null || link.OcspWebEndpoints.Count == 0)
throw new InvalidOperationException(OcspMessages.ErrNoAvailOCSPServers);
var timeout = TimeSpan.FromSeconds(opts.Timeout <= 0
? OcspPeerConfig.DefaultOCSPResponderTimeout.TotalSeconds
: opts.Timeout);
var reqEnc = EncodeOCSPRequest(ocspRequest);
using var hc = new HttpClient { Timeout = timeout };
Exception? lastError = null;
foreach (var endpoint in link.OcspWebEndpoints)
{
var responderUrl = endpoint.ToString().TrimEnd('/');
log?.Debug(OcspMessages.DbgMakingCARequest, responderUrl);
try
{
var url = $"{responderUrl}/{reqEnc}";
using var response = await hc.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException(
string.Format(OcspMessages.ErrBadResponderHTTPStatus, (int)response.StatusCode));
return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
lastError = ex;
}
}
throw new InvalidOperationException(
string.Format(OcspMessages.ErrFailedWithAllRequests, lastError?.Message), lastError);
}
/// <summary>
/// Base64-encodes the OCSP request DER bytes and URL-escapes the result
/// for use as a path segment (RFC 6960 Appendix A.1).
/// </summary>
public static string EncodeOCSPRequest(byte[] reqDer) =>
Uri.EscapeDataString(Convert.ToBase64String(reqDer));
}

View File

@@ -0,0 +1,219 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
namespace ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
/// <summary>
/// Utility methods for OCSP peer certificate validation.
/// Mirrors certidp/certidp.go.
/// </summary>
public static class OcspUtilities
{
// OCSP AIA extension OID.
private const string OidAuthorityInfoAccess = "1.3.6.1.5.5.7.1.1";
// OCSPSigning extended key usage OID.
private const string OidOcspSigning = "1.3.6.1.5.5.7.3.9";
/// <summary>Returns the SHA-256 fingerprint of the certificate's raw DER bytes, base64-encoded.</summary>
public static string GenerateFingerprint(X509Certificate2 cert)
{
var hash = SHA256.HashData(cert.RawData);
return Convert.ToBase64String(hash);
}
/// <summary>
/// Filters a list of URI strings to those that are valid HTTP or HTTPS URLs.
/// </summary>
public static IReadOnlyList<Uri> GetWebEndpoints(IEnumerable<string> uris)
{
var result = new List<Uri>();
foreach (var uri in uris)
{
if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed))
continue;
if (parsed.Scheme != "http" && parsed.Scheme != "https")
continue;
result.Add(parsed);
}
return result;
}
/// <summary>
/// Returns the certificate subject in RDN sequence form, for logging.
/// Not suitable for reliable cache matching.
/// </summary>
public static string GetSubjectDNForm(X509Certificate2? cert) =>
cert is null ? string.Empty : cert.Subject;
/// <summary>
/// Returns the certificate issuer in RDN sequence form, for logging.
/// Not suitable for reliable cache matching.
/// </summary>
public static string GetIssuerDNForm(X509Certificate2? cert) =>
cert is null ? string.Empty : cert.Issuer;
/// <summary>
/// Returns true if the leaf certificate in the chain has OCSP responder endpoints
/// in its Authority Information Access extension.
/// Also populates <see cref="ChainLink.OcspWebEndpoints"/> on the link.
/// </summary>
public static bool CertOCSPEligible(ChainLink? link)
{
if (link?.Leaf is null || link.Leaf.RawData is not { Length: > 0 })
return false;
var ocspUris = GetOcspUris(link.Leaf);
var endpoints = GetWebEndpoints(ocspUris);
if (endpoints.Count == 0)
return false;
link.OcspWebEndpoints = endpoints;
return true;
}
/// <summary>
/// Returns the issuer certificate at position <paramref name="leafPos"/> + 1 in the chain.
/// Returns null if the chain is too short or the leaf is self-signed.
/// </summary>
public static X509Certificate2? GetLeafIssuerCert(IReadOnlyList<X509Certificate2> chain, int leafPos)
{
if (chain.Count == 0 || leafPos < 0)
return null;
if (leafPos >= chain.Count - 1)
return null;
return chain[leafPos + 1];
}
/// <summary>
/// Returns true if the OCSP response is still current within the configured clock skew.
/// </summary>
public static bool OCSPResponseCurrent(OcspResponse response, OcspPeerConfig opts, OcspLog? log = null)
{
var skew = TimeSpan.FromSeconds(opts.ClockSkew < 0 ? OcspPeerConfig.DefaultAllowedClockSkew.TotalSeconds : opts.ClockSkew);
var now = DateTime.UtcNow;
// Check NextUpdate (when set by CA).
if (response.NextUpdate != DateTime.MinValue && response.NextUpdate < now - skew)
{
log?.Debug(OcspMessages.DbgResponseExpired,
response.NextUpdate.ToString("o"), now.ToString("o"), skew);
return false;
}
// If NextUpdate not set, apply TTL from ThisUpdate.
if (response.NextUpdate == DateTime.MinValue)
{
var ttl = TimeSpan.FromSeconds(opts.TTLUnsetNextUpdate < 0
? OcspPeerConfig.DefaultTTLUnsetNextUpdate.TotalSeconds
: opts.TTLUnsetNextUpdate);
var expiry = response.ThisUpdate + ttl;
if (expiry < now - skew)
{
log?.Debug(OcspMessages.DbgResponseTTLExpired,
expiry.ToString("o"), now.ToString("o"), skew);
return false;
}
}
// Check ThisUpdate is not future-dated.
if (response.ThisUpdate > now + skew)
{
log?.Debug(OcspMessages.DbgResponseFutureDated,
response.ThisUpdate.ToString("o"), now.ToString("o"), skew);
return false;
}
return true;
}
/// <summary>
/// Validates that the OCSP response was signed by a valid CA issuer or authorised delegate
/// per RFC 6960 §4.2.2.2.
/// </summary>
public static bool ValidDelegationCheck(X509Certificate2? issuer, OcspResponse? response)
{
if (issuer is null || response is null)
return false;
// Not a delegated response — the CA signed directly.
if (response.Certificate is null)
return true;
// Delegate is the same as the issuer — effectively a direct signing.
if (response.Certificate.Thumbprint == issuer.Thumbprint)
return true;
// Check the delegate has id-kp-OCSPSigning in its extended key usage.
foreach (var ext in response.Certificate.Extensions)
{
if (ext is not X509EnhancedKeyUsageExtension eku)
continue;
foreach (var oid in eku.EnhancedKeyUsages)
{
if (oid.Value == OidOcspSigning)
return true;
}
}
return false;
}
// --- Helpers ---
private static IEnumerable<string> GetOcspUris(X509Certificate2 cert)
{
foreach (var ext in cert.Extensions)
{
if (ext.Oid?.Value != OidAuthorityInfoAccess)
continue;
foreach (var uri in ParseAiaUris(ext.RawData, isOcsp: true))
yield return uri;
}
}
private static List<string> ParseAiaUris(byte[] aiaExtDer, bool isOcsp)
{
// OID for id-ad-ocsp: 1.3.6.1.5.5.7.48.1 → 2B 06 01 05 05 07 30 01
byte[] ocspOid = [0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x01];
// OID for id-ad-caIssuers: 1.3.6.1.5.5.7.48.2 → 2B 06 01 05 05 07 30 02
byte[] caIssuersOid = [0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x02];
var target = isOcsp ? ocspOid : caIssuersOid;
var result = new List<string>();
int i = 0;
while (i < aiaExtDer.Length - target.Length - 4)
{
// Look for OID tag (0x06) followed by length matching our OID.
if (aiaExtDer[i] == 0x06 && i + 1 < aiaExtDer.Length && aiaExtDer[i + 1] == target.Length)
{
var match = true;
for (int k = 0; k < target.Length; k++)
{
if (aiaExtDer[i + 2 + k] != target[k]) { match = false; break; }
}
if (match)
{
// Next element should be context [6] IA5String (GeneralName uniformResourceIdentifier).
int pos = i + 2 + target.Length;
if (pos < aiaExtDer.Length && aiaExtDer[pos] == 0x86)
{
pos++;
if (pos < aiaExtDer.Length)
{
int len = aiaExtDer[pos++];
if (pos + len <= aiaExtDer.Length)
{
result.Add(System.Text.Encoding.ASCII.GetString(aiaExtDer, pos, len));
i = pos + len;
continue;
}
}
}
}
}
i++;
}
return result;
}
}

View File

@@ -0,0 +1,137 @@
// Copyright 2022-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.
using System.Security.Cryptography.X509Certificates;
namespace ZB.MOM.NatsNet.Server.Auth.CertificateStore;
/// <summary>
/// Windows certificate store location.
/// Mirrors the Go certstore <c>StoreType</c> enum (windowsCurrentUser=1, windowsLocalMachine=2).
/// </summary>
public enum StoreType
{
Empty = 0,
WindowsCurrentUser = 1,
WindowsLocalMachine = 2,
}
/// <summary>
/// Certificate lookup criterion.
/// Mirrors the Go certstore <c>MatchByType</c> enum (matchByIssuer=1, matchBySubject=2, matchByThumbprint=3).
/// </summary>
public enum MatchByType
{
Empty = 0,
Issuer = 1,
Subject = 2,
Thumbprint = 3,
}
/// <summary>
/// Result returned by <see cref="CertificateStoreService.TLSConfig"/>.
/// Mirrors the data that the Go <c>TLSConfig</c> populates into <c>*tls.Config</c>.
/// </summary>
public sealed class CertStoreTlsResult
{
public CertStoreTlsResult(X509Certificate2 leaf, X509Certificate2Collection? caCerts = null)
{
Leaf = leaf;
CaCerts = caCerts;
}
/// <summary>The leaf certificate (with private key) to use as the server/client identity.</summary>
public X509Certificate2 Leaf { get; }
/// <summary>Optional pool of CA certificates used to validate client certificates (mTLS).</summary>
public X509Certificate2Collection? CaCerts { get; }
}
/// <summary>
/// Error constants for the Windows certificate store module.
/// Mirrors certstore/errors.go.
/// </summary>
public static class CertStoreErrors
{
public static readonly InvalidOperationException ErrBadCryptoStoreProvider =
new("unable to open certificate store or store not available");
public static readonly InvalidOperationException ErrBadRSAHashAlgorithm =
new("unsupported RSA hash algorithm");
public static readonly InvalidOperationException ErrBadSigningAlgorithm =
new("unsupported signing algorithm");
public static readonly InvalidOperationException ErrStoreRSASigningError =
new("unable to obtain RSA signature from store");
public static readonly InvalidOperationException ErrStoreECDSASigningError =
new("unable to obtain ECDSA signature from store");
public static readonly InvalidOperationException ErrNoPrivateKeyStoreRef =
new("unable to obtain private key handle from store");
public static readonly InvalidOperationException ErrExtractingPrivateKeyMetadata =
new("unable to extract private key metadata");
public static readonly InvalidOperationException ErrExtractingECCPublicKey =
new("unable to extract ECC public key from store");
public static readonly InvalidOperationException ErrExtractingRSAPublicKey =
new("unable to extract RSA public key from store");
public static readonly InvalidOperationException ErrExtractingPublicKey =
new("unable to extract public key from store");
public static readonly InvalidOperationException ErrBadPublicKeyAlgorithm =
new("unsupported public key algorithm");
public static readonly InvalidOperationException ErrExtractPropertyFromKey =
new("unable to extract property from key");
public static readonly InvalidOperationException ErrBadECCCurveName =
new("unsupported ECC curve name");
public static readonly InvalidOperationException ErrFailedCertSearch =
new("unable to find certificate in store");
public static readonly InvalidOperationException ErrFailedX509Extract =
new("unable to extract x509 from certificate");
public static readonly InvalidOperationException ErrBadMatchByType =
new("cert match by type not implemented");
public static readonly InvalidOperationException ErrBadCertStore =
new("cert store type not implemented");
public static readonly InvalidOperationException ErrConflictCertFileAndStore =
new("'cert_file' and 'cert_store' may not both be configured");
public static readonly InvalidOperationException ErrBadCertStoreField =
new("expected 'cert_store' to be a valid non-empty string");
public static readonly InvalidOperationException ErrBadCertMatchByField =
new("expected 'cert_match_by' to be a valid non-empty string");
public static readonly InvalidOperationException ErrBadCertMatchField =
new("expected 'cert_match' to be a valid non-empty string");
public static readonly InvalidOperationException ErrBadCaCertMatchField =
new("expected 'ca_certs_match' to be a valid non-empty string array");
public static readonly InvalidOperationException ErrBadCertMatchSkipInvalidField =
new("expected 'cert_match_skip_invalid' to be a boolean");
public static readonly InvalidOperationException ErrOSNotCompatCertStore =
new("cert_store not compatible with current operating system");
}

View File

@@ -0,0 +1,264 @@
// Copyright 2022-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 certstore/certstore.go and certstore/certstore_windows.go in
// the NATS server Go source. The .NET implementation uses System.Security.
// Cryptography.X509Certificates.X509Store in place of Win32 P/Invoke calls.
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
namespace ZB.MOM.NatsNet.Server.Auth.CertificateStore;
/// <summary>
/// Provides access to the Windows certificate store for TLS certificate provisioning.
/// Mirrors certstore/certstore.go and certstore/certstore_windows.go.
///
/// On non-Windows platforms all methods that require the Windows store throw
/// <see cref="CertStoreErrors.ErrOSNotCompatCertStore"/>.
/// </summary>
public static class CertificateStoreService
{
private static readonly IReadOnlyDictionary<string, StoreType> StoreMap =
new Dictionary<string, StoreType>(StringComparer.OrdinalIgnoreCase)
{
["windowscurrentuser"] = StoreType.WindowsCurrentUser,
["windowslocalmachine"] = StoreType.WindowsLocalMachine,
};
private static readonly IReadOnlyDictionary<string, MatchByType> MatchByMap =
new Dictionary<string, MatchByType>(StringComparer.OrdinalIgnoreCase)
{
["issuer"] = MatchByType.Issuer,
["subject"] = MatchByType.Subject,
["thumbprint"] = MatchByType.Thumbprint,
};
// -------------------------------------------------------------------------
// Cross-platform parse helpers
// -------------------------------------------------------------------------
/// <summary>
/// Parses a cert_store string to a <see cref="StoreType"/>.
/// Returns an error if the string is unrecognised or not valid on the current OS.
/// Mirrors <c>ParseCertStore</c>.
/// </summary>
public static (StoreType store, Exception? error) ParseCertStore(string certStore)
{
if (!StoreMap.TryGetValue(certStore, out var st))
return (StoreType.Empty, CertStoreErrors.ErrBadCertStore);
// All currently supported store types are Windows-only.
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return (StoreType.Empty, CertStoreErrors.ErrOSNotCompatCertStore);
return (st, null);
}
/// <summary>
/// Parses a cert_match_by string to a <see cref="MatchByType"/>.
/// Mirrors <c>ParseCertMatchBy</c>.
/// </summary>
public static (MatchByType matchBy, Exception? error) ParseCertMatchBy(string certMatchBy)
{
if (!MatchByMap.TryGetValue(certMatchBy, out var mb))
return (MatchByType.Empty, CertStoreErrors.ErrBadMatchByType);
return (mb, null);
}
/// <summary>
/// Returns the issuer certificate for <paramref name="leaf"/> by building a chain.
/// Returns null if the chain cannot be built or the leaf is self-signed.
/// Mirrors <c>GetLeafIssuer</c>.
/// </summary>
public static X509Certificate2? GetLeafIssuer(X509Certificate2 leaf)
{
using var chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
if (!chain.Build(leaf) || chain.ChainElements.Count < 2)
return null;
// chain.ChainElements[0] is the leaf; [1] is its issuer.
return new X509Certificate2(chain.ChainElements[1].Certificate);
}
// -------------------------------------------------------------------------
// TLS configuration entry point
// -------------------------------------------------------------------------
/// <summary>
/// Finds a certificate in the Windows certificate store matching the given criteria and
/// returns a <see cref="CertStoreTlsResult"/> suitable for populating TLS options.
///
/// On non-Windows platforms throws <see cref="CertStoreErrors.ErrOSNotCompatCertStore"/>.
/// Mirrors <c>TLSConfig</c> (certstore_windows.go).
/// </summary>
/// <param name="storeType">Which Windows store to use (CurrentUser or LocalMachine).</param>
/// <param name="matchBy">How to match the certificate (Subject, Issuer, or Thumbprint).</param>
/// <param name="certMatch">The match value (subject name, issuer name, or thumbprint hex).</param>
/// <param name="caCertsMatch">Optional list of subject strings to locate CA certificates.</param>
/// <param name="skipInvalid">If true, skip expired or not-yet-valid certificates.</param>
public static CertStoreTlsResult TLSConfig(
StoreType storeType,
MatchByType matchBy,
string certMatch,
IReadOnlyList<string>? caCertsMatch = null,
bool skipInvalid = false)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
throw CertStoreErrors.ErrOSNotCompatCertStore;
if (storeType is not (StoreType.WindowsCurrentUser or StoreType.WindowsLocalMachine))
throw CertStoreErrors.ErrBadCertStore;
var location = storeType == StoreType.WindowsCurrentUser
? StoreLocation.CurrentUser
: StoreLocation.LocalMachine;
// Find the leaf certificate.
var leaf = matchBy switch
{
MatchByType.Subject or MatchByType.Empty => CertBySubject(certMatch, location, skipInvalid),
MatchByType.Issuer => CertByIssuer(certMatch, location, skipInvalid),
MatchByType.Thumbprint => CertByThumbprint(certMatch, location, skipInvalid),
_ => throw CertStoreErrors.ErrBadMatchByType,
} ?? throw CertStoreErrors.ErrFailedCertSearch;
// Optionally find CA certificates.
X509Certificate2Collection? caPool = null;
if (caCertsMatch is { Count: > 0 })
caPool = CreateCACertsPool(location, caCertsMatch, skipInvalid);
return new CertStoreTlsResult(leaf, caPool);
}
// -------------------------------------------------------------------------
// Certificate search helpers (mirror winCertStore.certByXxx / certSearch)
// -------------------------------------------------------------------------
/// <summary>
/// Finds the first certificate in the personal (MY) store by subject name.
/// Mirrors <c>certBySubject</c>.
/// </summary>
public static X509Certificate2? CertBySubject(string subject, StoreLocation location, bool skipInvalid) =>
CertSearch(StoreName.My, location, X509FindType.FindBySubjectName, subject, skipInvalid);
/// <summary>
/// Finds the first certificate in the personal (MY) store by issuer name.
/// Mirrors <c>certByIssuer</c>.
/// </summary>
public static X509Certificate2? CertByIssuer(string issuer, StoreLocation location, bool skipInvalid) =>
CertSearch(StoreName.My, location, X509FindType.FindByIssuerName, issuer, skipInvalid);
/// <summary>
/// Finds the first certificate in the personal (MY) store by SHA-1 thumbprint (hex string).
/// Mirrors <c>certByThumbprint</c>.
/// </summary>
public static X509Certificate2? CertByThumbprint(string thumbprint, StoreLocation location, bool skipInvalid) =>
CertSearch(StoreName.My, location, X509FindType.FindByThumbprint, thumbprint, skipInvalid);
/// <summary>
/// Searches Root, AuthRoot, and CA stores for certificates matching the given subject name.
/// Returns all matching certificates across all three locations.
/// Mirrors <c>caCertsBySubjectMatch</c>.
/// </summary>
public static IReadOnlyList<X509Certificate2> CaCertsBySubjectMatch(
string subject,
StoreLocation location,
bool skipInvalid)
{
if (string.IsNullOrEmpty(subject))
throw CertStoreErrors.ErrBadCaCertMatchField;
var results = new List<X509Certificate2>();
var searchLocations = new[] { StoreName.Root, StoreName.AuthRoot, StoreName.CertificateAuthority };
foreach (var storeName in searchLocations)
{
var cert = CertSearch(storeName, location, X509FindType.FindBySubjectName, subject, skipInvalid);
if (cert != null)
results.Add(cert);
}
if (results.Count == 0)
throw CertStoreErrors.ErrFailedCertSearch;
return results;
}
/// <summary>
/// Core certificate search — opens the specified store and finds a matching certificate.
/// Returns null if not found.
/// Mirrors <c>certSearch</c>.
/// </summary>
public static X509Certificate2? CertSearch(
StoreName storeName,
StoreLocation storeLocation,
X509FindType findType,
string findValue,
bool skipInvalid)
{
using var store = new X509Store(storeName, storeLocation, OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
var certs = store.Certificates.Find(findType, findValue, validOnly: skipInvalid);
if (certs.Count == 0)
return null;
// Pick first that has a private key (mirrors certKey requirement in Go).
foreach (var cert in certs)
{
if (cert.HasPrivateKey)
return cert;
}
// Fall back to first even without private key (e.g. CA cert lookup).
return certs[0];
}
// -------------------------------------------------------------------------
// CA cert pool builder (mirrors createCACertsPool)
// -------------------------------------------------------------------------
/// <summary>
/// Builds a collection of CA certificates from the trusted Root, AuthRoot, and CA stores
/// for each subject name in <paramref name="caCertsMatch"/>.
/// Mirrors <c>createCACertsPool</c>.
/// </summary>
public static X509Certificate2Collection CreateCACertsPool(
StoreLocation location,
IReadOnlyList<string> caCertsMatch,
bool skipInvalid)
{
var pool = new X509Certificate2Collection();
var failCount = 0;
foreach (var subject in caCertsMatch)
{
try
{
var matches = CaCertsBySubjectMatch(subject, location, skipInvalid);
foreach (var cert in matches)
pool.Add(cert);
}
catch
{
failCount++;
}
}
if (failCount == caCertsMatch.Count)
throw new InvalidOperationException("unable to match any CA certificate");
return pool;
}
}

View File

@@ -0,0 +1,110 @@
// Copyright 2016-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/ciphersuites.go in the NATS server Go source.
using System.Net.Security;
using System.Security.Authentication;
namespace ZB.MOM.NatsNet.Server.Auth;
/// <summary>
/// TLS cipher suite and curve preference definitions.
/// Mirrors Go <c>ciphersuites.go</c> — cipherMap, defaultCipherSuites, curvePreferenceMap,
/// defaultCurvePreferences.
/// </summary>
public static class CipherSuites
{
/// <summary>
/// Map of cipher suite names to their <see cref="TlsCipherSuite"/> values.
/// Populated at static init time — mirrors Go <c>init()</c> + <c>cipherMap</c>.
/// </summary>
public static IReadOnlyDictionary<string, TlsCipherSuite> CipherMap { get; }
/// <summary>
/// Reverse map of cipher suite ID to name.
/// Mirrors Go <c>cipherMapByID</c>.
/// </summary>
public static IReadOnlyDictionary<TlsCipherSuite, string> CipherMapById { get; }
static CipherSuites()
{
// .NET does not have a direct equivalent of Go's tls.CipherSuites() /
// tls.InsecureCipherSuites() enumeration. We enumerate the well-known
// TLS 1.2 and 1.3 cipher suites defined in the TlsCipherSuite enum.
var byName = new Dictionary<string, TlsCipherSuite>(StringComparer.OrdinalIgnoreCase);
var byId = new Dictionary<TlsCipherSuite, string>();
foreach (TlsCipherSuite cs in Enum.GetValues(typeof(TlsCipherSuite)))
{
var name = cs.ToString();
byName.TryAdd(name, cs);
byId.TryAdd(cs, name);
}
CipherMap = byName;
CipherMapById = byId;
}
/// <summary>
/// Returns the default set of TLS 1.2 cipher suites.
/// .NET manages cipher suite selection at the OS/SChannel/OpenSSL level;
/// this list provides the preferred suites for configuration alignment with Go.
/// Mirrors Go <c>defaultCipherSuites</c>.
/// </summary>
public static TlsCipherSuite[] DefaultCipherSuites()
{
// Return commonly-used TLS 1.2 cipher suites in preference order.
// TLS 1.3 suites are always enabled in .NET and cannot be individually toggled.
return
[
TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
TlsCipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
TlsCipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
TlsCipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
];
}
/// <summary>
/// Supported named curve / key exchange preferences.
/// Mirrors Go <c>curvePreferenceMap</c>.
/// </summary>
public static IReadOnlyDictionary<string, SslApplicationProtocol> CurvePreferenceMap { get; } =
new Dictionary<string, SslApplicationProtocol>(StringComparer.OrdinalIgnoreCase)
{
// .NET does not expose individual curve selection in the same way as Go.
// These entries exist for configuration-file compatibility and mapping.
// Actual curve negotiation is handled by the OS TLS stack.
["X25519"] = new SslApplicationProtocol("X25519"),
["CurveP256"] = new SslApplicationProtocol("CurveP256"),
["CurveP384"] = new SslApplicationProtocol("CurveP384"),
["CurveP521"] = new SslApplicationProtocol("CurveP521"),
};
/// <summary>
/// Returns the default curve preferences, ordered highest security first.
/// Mirrors Go <c>defaultCurvePreferences</c>.
/// </summary>
public static string[] DefaultCurvePreferences()
{
return
[
"X25519",
"CurveP256",
"CurveP384",
"CurveP521",
];
}
}

View File

@@ -0,0 +1,192 @@
// Copyright 2018-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/jwt.go in the NATS server Go source.
using System.Net;
namespace ZB.MOM.NatsNet.Server.Auth;
/// <summary>
/// JWT processing utilities for NATS operator/account/user JWTs.
/// Mirrors Go <c>jwt.go</c> functions.
/// Full JWT parsing will be added when a .NET JWT library equivalent is available.
/// </summary>
public static class JwtProcessor
{
/// <summary>
/// All JWTs once encoded start with this prefix.
/// Mirrors Go <c>jwtPrefix</c>.
/// </summary>
public const string JwtPrefix = "eyJ";
/// <summary>
/// Wipes a byte slice by filling with 'x', for clearing nkey seed data.
/// Mirrors Go <c>wipeSlice</c>.
/// </summary>
public static void WipeSlice(Span<byte> buf)
{
buf.Fill((byte)'x');
}
/// <summary>
/// Validates that the given IP host address is allowed by the user claims source CIDRs.
/// Returns true if the host is within any of the allowed CIDRs, or if no CIDRs are specified.
/// Mirrors Go <c>validateSrc</c>.
/// </summary>
public static bool ValidateSrc(IReadOnlyList<string>? srcCidrs, string host)
{
if (srcCidrs == null)
{
return false;
}
if (srcCidrs.Count == 0)
{
return true;
}
if (string.IsNullOrEmpty(host))
{
return false;
}
if (!IPAddress.TryParse(host, out var ip))
{
return false;
}
foreach (var cidr in srcCidrs)
{
if (TryParseCidr(cidr, out var network, out var prefixLength))
{
if (IsInSubnet(ip, network, prefixLength))
{
return true;
}
}
else
{
return false; // invalid CIDR means invalid JWT
}
}
return false;
}
/// <summary>
/// Validates that the current time falls within any of the allowed time ranges.
/// Returns (allowed, remainingDuration).
/// Mirrors Go <c>validateTimes</c>.
/// </summary>
public static (bool Allowed, TimeSpan Remaining) ValidateTimes(
IReadOnlyList<TimeRange>? timeRanges,
string? locale = null)
{
if (timeRanges == null)
{
return (false, TimeSpan.Zero);
}
if (timeRanges.Count == 0)
{
return (true, TimeSpan.Zero);
}
var now = DateTimeOffset.Now;
TimeZoneInfo? tz = null;
if (!string.IsNullOrEmpty(locale))
{
try
{
tz = TimeZoneInfo.FindSystemTimeZoneById(locale);
now = TimeZoneInfo.ConvertTime(now, tz);
}
catch
{
return (false, TimeSpan.Zero);
}
}
foreach (var timeRange in timeRanges)
{
if (!TimeSpan.TryParse(timeRange.Start, out var startTime) ||
!TimeSpan.TryParse(timeRange.End, out var endTime))
{
return (false, TimeSpan.Zero);
}
var today = now.Date;
var start = today + startTime;
var end = today + endTime;
// If start > end, end is on the next day (overnight range).
if (startTime > endTime)
{
end = end.AddDays(1);
}
if (start <= now && now < end)
{
return (true, end - now);
}
}
return (false, TimeSpan.Zero);
}
private static bool TryParseCidr(string cidr, out IPAddress network, out int prefixLength)
{
network = IPAddress.None;
prefixLength = 0;
var slashIndex = cidr.IndexOf('/');
if (slashIndex < 0) return false;
var ipPart = cidr.AsSpan(0, slashIndex);
var prefixPart = cidr.AsSpan(slashIndex + 1);
if (!IPAddress.TryParse(ipPart, out var parsedIp)) return false;
if (!int.TryParse(prefixPart, out var prefix)) return false;
network = parsedIp;
prefixLength = prefix;
return true;
}
private static bool IsInSubnet(IPAddress address, IPAddress network, int prefixLength)
{
var addrBytes = address.GetAddressBytes();
var netBytes = network.GetAddressBytes();
if (addrBytes.Length != netBytes.Length) return false;
var fullBytes = prefixLength / 8;
var remainingBits = prefixLength % 8;
for (var i = 0; i < fullBytes; i++)
{
if (addrBytes[i] != netBytes[i]) return false;
}
if (remainingBits > 0 && fullBytes < addrBytes.Length)
{
var mask = (byte)(0xFF << (8 - remainingBits));
if ((addrBytes[fullBytes] & mask) != (netBytes[fullBytes] & mask)) return false;
}
return true;
}
}
/// <summary>
/// Represents a time-of-day range for user access control.
/// Mirrors Go <c>jwt.TimeRange</c>.
/// </summary>
public class TimeRange
{
public string Start { get; set; } = string.Empty;
public string End { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,61 @@
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ZB.MOM.NatsNet.Server.Auth;
/// <summary>
/// Provides JetStream encryption key management via the Trusted Platform Module (TPM).
/// Windows only — non-Windows platforms throw <see cref="PlatformNotSupportedException"/>.
/// </summary>
/// <remarks>
/// On Windows, the full implementation requires the Tpm2Lib NuGet package and accesses
/// the TPM to seal/unseal keys using PCR-based authorization. The sealed public and
/// private key blobs are persisted to disk as JSON.
/// </remarks>
public static class TpmKeyProvider
{
/// <summary>
/// Loads (or creates) the JetStream encryption key from the TPM.
/// On first call (key file does not exist), generates a new NKey seed, seals it to the
/// TPM, and writes the blobs to <paramref name="jsKeyFile"/>.
/// On subsequent calls, reads the blobs from disk and unseals them using the TPM.
/// </summary>
/// <param name="srkPassword">Storage Root Key password (may be empty).</param>
/// <param name="jsKeyFile">Path to the persisted key blobs JSON file.</param>
/// <param name="jsKeyPassword">Password used to seal/unseal the JetStream key.</param>
/// <param name="pcr">PCR index to bind the authorization policy to.</param>
/// <returns>The JetStream encryption key seed string.</returns>
/// <exception cref="PlatformNotSupportedException">Thrown on non-Windows platforms.</exception>
public static string LoadJetStreamEncryptionKeyFromTpm(
string srkPassword,
string jsKeyFile,
string jsKeyPassword,
int pcr)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
throw new PlatformNotSupportedException("TPM functionality is not supported on this platform.");
// Windows implementation requires Tpm2Lib NuGet package.
// Add <PackageReference Include="Tpm2Lib" Version="*" /> to the .csproj
// under a Windows-conditional ItemGroup before enabling this path.
throw new PlatformNotSupportedException(
"TPM functionality is not supported on this platform. " +
"On Windows, add Tpm2Lib NuGet package and implement via tpm2.OpenTPM().");
}
}
/// <summary>
/// Persisted TPM key blobs stored on disk as JSON.
/// </summary>
internal sealed class NatsPersistedTpmKeys
{
[JsonPropertyName("version")]
public int Version { get; set; }
[JsonPropertyName("private_key")]
public byte[] PrivateKey { get; set; } = [];
[JsonPropertyName("public_key")]
public byte[] PublicKey { get; set; } = [];
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,375 @@
// Copyright 2012-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/client.go in the NATS server Go source.
using System.Text.Json.Serialization;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// Client connection kind (iota constants)
// ============================================================================
// Note: ClientKind is already declared in Internal/Subscription.cs; this file
// adds the remaining constants that were used only here.
/// <summary>
/// Extended client connection type (returned by <c>clientType()</c>).
/// Maps Go's NON_CLIENT / NATS / MQTT / WS iota.
/// </summary>
public enum ClientConnectionType
{
/// <summary>Connection is not a CLIENT kind.</summary>
NonClient = 0,
/// <summary>Regular NATS client.</summary>
Nats = 1,
/// <summary>MQTT client.</summary>
Mqtt = 2,
/// <summary>WebSocket client.</summary>
WebSocket = 3,
}
// ============================================================================
// Client protocol versions
// ============================================================================
/// <summary>
/// Wire protocol version negotiated in the CONNECT message.
/// </summary>
public static class ClientProtocol
{
/// <summary>Original protocol (2009). Mirrors <c>ClientProtoZero</c>.</summary>
public const int Zero = 0;
/// <summary>Protocol that supports INFO updates. Mirrors <c>ClientProtoInfo</c>.</summary>
public const int Info = 1;
}
// ============================================================================
// WriteTimeoutPolicy extension (enum defined in ServerOptionTypes.cs)
// ============================================================================
internal static class WriteTimeoutPolicyExtensions
{
/// <summary>Mirrors Go <c>WriteTimeoutPolicy.String()</c>.</summary>
public static string ToVarzString(this WriteTimeoutPolicy p) => p switch
{
WriteTimeoutPolicy.Close => "close",
WriteTimeoutPolicy.Retry => "retry",
_ => string.Empty,
};
}
// ============================================================================
// ClientFlags
// ============================================================================
/// <summary>
/// Compact bitfield of boolean client state.
/// Mirrors Go <c>clientFlag</c> and its iota constants.
/// </summary>
[Flags]
public enum ClientFlags : ushort
{
None = 0,
ConnectReceived = 1 << 0,
InfoReceived = 1 << 1,
FirstPongSent = 1 << 2,
HandshakeComplete = 1 << 3,
FlushOutbound = 1 << 4,
NoReconnect = 1 << 5,
CloseConnection = 1 << 6,
ConnMarkedClosed = 1 << 7,
WriteLoopStarted = 1 << 8,
SkipFlushOnClose = 1 << 9,
ExpectConnect = 1 << 10,
ConnectProcessFinished = 1 << 11,
CompressionNegotiated = 1 << 12,
DidTlsFirst = 1 << 13,
IsSlowConsumer = 1 << 14,
FirstPong = 1 << 15,
}
// ============================================================================
// ReadCacheFlags
// ============================================================================
/// <summary>
/// Bitfield for the read-cache loop state.
/// Mirrors Go <c>readCacheFlag</c>.
/// </summary>
[Flags]
public enum ReadCacheFlags : ushort
{
None = 0,
HasMappings = 1 << 0,
SwitchToCompression = 1 << 1,
}
// ============================================================================
// ClosedState
// ============================================================================
/// <summary>
/// The reason a client connection was closed.
/// Mirrors Go <c>ClosedState</c>.
/// </summary>
public enum ClosedState
{
ClientClosed = 1,
AuthenticationTimeout,
AuthenticationViolation,
TlsHandshakeError,
SlowConsumerPendingBytes,
SlowConsumerWriteDeadline,
WriteError,
ReadError,
ParseError,
StaleConnection,
ProtocolViolation,
BadClientProtocolVersion,
WrongPort,
MaxAccountConnectionsExceeded,
MaxConnectionsExceeded,
MaxPayloadExceeded,
MaxControlLineExceeded,
MaxSubscriptionsExceeded,
DuplicateRoute,
RouteRemoved,
ServerShutdown,
AuthenticationExpired,
WrongGateway,
MissingAccount,
Revocation,
InternalClient,
MsgHeaderViolation,
NoRespondersRequiresHeaders,
ClusterNameConflict,
DuplicateRemoteLeafnodeConnection,
DuplicateClientId,
DuplicateServerName,
MinimumVersionRequired,
ClusterNamesIdentical,
Kicked,
ProxyNotTrusted,
ProxyRequired,
}
// ============================================================================
// processMsgResults flags
// ============================================================================
/// <summary>
/// Flags passed to <c>ProcessMsgResults</c>.
/// Mirrors Go <c>pmrNoFlag</c> and the iota block.
/// </summary>
[Flags]
public enum PmrFlags
{
None = 0,
CollectQueueNames = 1 << 0,
IgnoreEmptyQueueFilter = 1 << 1,
AllowSendFromRouteToRoute = 1 << 2,
MsgImportedFromService = 1 << 3,
}
// ============================================================================
// denyType
// ============================================================================
/// <summary>
/// Which permission side to apply deny-list merging to.
/// Mirrors Go <c>denyType</c>.
/// </summary>
internal enum DenyType
{
Pub = 1,
Sub = 2,
Both = 3,
}
// ============================================================================
// ClientOptions (wire-protocol CONNECT options)
// ============================================================================
/// <summary>
/// Options negotiated during the CONNECT handshake.
/// Mirrors Go <c>ClientOpts</c>.
/// </summary>
public sealed class ClientOptions
{
[JsonPropertyName("echo")] public bool Echo { get; set; }
[JsonPropertyName("verbose")] public bool Verbose { get; set; }
[JsonPropertyName("pedantic")] public bool Pedantic { get; set; }
[JsonPropertyName("tls_required")] public bool TlsRequired { get; set; }
[JsonPropertyName("nkey")] public string Nkey { get; set; } = string.Empty;
[JsonPropertyName("jwt")] public string Jwt { get; set; } = string.Empty;
[JsonPropertyName("sig")] public string Sig { get; set; } = string.Empty;
[JsonPropertyName("auth_token")] public string Token { get; set; } = string.Empty;
[JsonPropertyName("user")] public string Username { get; set; } = string.Empty;
[JsonPropertyName("pass")] public string Password { get; set; } = string.Empty;
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("lang")] public string Lang { get; set; } = string.Empty;
[JsonPropertyName("version")] public string Version { get; set; } = string.Empty;
[JsonPropertyName("protocol")] public int Protocol { get; set; }
[JsonPropertyName("account")] public string Account { get; set; } = string.Empty;
[JsonPropertyName("new_account")] public bool AccountNew { get; set; }
[JsonPropertyName("headers")] public bool Headers { get; set; }
[JsonPropertyName("no_responders")]public bool NoResponders { get; set; }
// Routes and Leaf Nodes only
[JsonPropertyName("import")] public SubjectPermission? Import { get; set; }
[JsonPropertyName("export")] public SubjectPermission? Export { get; set; }
[JsonPropertyName("remote_account")] public string RemoteAccount { get; set; } = string.Empty;
[JsonPropertyName("proxy_sig")] public string ProxySig { get; set; } = string.Empty;
/// <summary>Default options for external clients.</summary>
public static ClientOptions Default => new() { Verbose = true, Pedantic = true, Echo = true };
/// <summary>Default options for internal server clients.</summary>
public static ClientOptions Internal => new() { Verbose = false, Pedantic = false, Echo = false };
}
// ============================================================================
// ClientInfo — lightweight metadata sent in server events
// ============================================================================
/// <summary>
/// Client metadata included in server monitoring events.
/// Mirrors Go <c>ClientInfo</c>.
/// </summary>
public sealed class ClientInfo
{
public string Start { get; set; } = string.Empty;
public string Host { get; set; } = string.Empty;
public ulong Id { get; set; }
public string Account { get; set; } = string.Empty;
public string User { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Lang { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public string Jwt { get; set; } = string.Empty;
public string IssuerKey { get; set; } = string.Empty;
public string NameTag { get; set; } = string.Empty;
public List<string> Tags { get; set; } = [];
public string Kind { get; set; } = string.Empty;
public string ClientType { get; set; } = string.Empty;
public string? MqttId { get; set; }
public bool Stop { get; set; }
public bool Restart { get; set; }
public bool Disconnect { get; set; }
public string[]? Cluster { get; set; }
public bool Service { get; set; }
}
// ============================================================================
// Internal permission structures (not public API)
// (Permissions, SubjectPermission, ResponsePermission are in Auth/AuthTypes.cs)
// ============================================================================
internal sealed class Perm
{
public SubscriptionIndex? Allow { get; set; }
public SubscriptionIndex? Deny { get; set; }
}
internal sealed class ClientPermissions
{
public int PcsZ; // pub cache size (atomic)
public int PRun; // prune run count (atomic)
public Perm Sub { get; } = new();
public Perm Pub { get; } = new();
public ResponsePermission? Resp { get; set; }
// Per-subject cache for permission checks.
public Dictionary<string, bool> PCache { get; } = new(StringComparer.Ordinal);
}
internal sealed class MsgDeny
{
public SubscriptionIndex? Deny { get; set; }
public Dictionary<string, bool> DCache { get; } = new(StringComparer.Ordinal);
}
internal sealed class RespEntry
{
public DateTime Time { get; set; }
public int N { get; set; }
}
// ============================================================================
// Buffer pool constants
// ============================================================================
internal static class NbPool
{
internal const int SmallSize = 512;
internal const int MediumSize = 4096;
internal const int LargeSize = 65536;
private static readonly System.Buffers.ArrayPool<byte> _pool =
System.Buffers.ArrayPool<byte>.Create(LargeSize, 50);
/// <summary>
/// Returns a buffer best-effort sized to <paramref name="sz"/>.
/// Mirrors Go <c>nbPoolGet</c>.
/// </summary>
public static byte[] Get(int sz)
{
int cap = sz <= SmallSize ? SmallSize
: sz <= MediumSize ? MediumSize
: LargeSize;
return _pool.Rent(cap);
}
/// <summary>
/// Returns a buffer to the pool.
/// Mirrors Go <c>nbPoolPut</c>.
/// </summary>
public static void Put(byte[] buf)
{
if (buf.Length == SmallSize || buf.Length == MediumSize || buf.Length == LargeSize)
_pool.Return(buf);
// Ignore wrong-sized frames (WebSocket/MQTT).
}
}
// ============================================================================
// Route / gateway / leaf / websocket / mqtt stubs
// (These are filled in during sessions 14-16 and 22-23)
// ============================================================================
internal sealed class RouteTarget
{
public Subscription? Sub { get; set; }
public byte[] Qs { get; set; } = [];
}
// ============================================================================
// Static helper: IsInternalClient
// ============================================================================
/// <summary>
/// Client-kind classification helpers.
/// </summary>
public static class ClientKindHelpers
{
/// <summary>
/// Returns <c>true</c> if <paramref name="kind"/> is an internal server client.
/// Mirrors Go <c>isInternalClient</c>.
/// </summary>
public static bool IsInternalClient(ClientKind kind) =>
kind == ClientKind.System || kind == ClientKind.JetStream || kind == ClientKind.Account;
}

View File

@@ -0,0 +1,100 @@
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// Provides an efficiently-cached Unix nanosecond timestamp updated every
/// <see cref="TickInterval"/> by a shared background timer.
/// Register before use and Unregister when done; the timer shuts down when all
/// registrants have unregistered.
/// </summary>
/// <remarks>
/// Mirrors the Go <c>ats</c> package. Intended for high-frequency cache
/// access-time reads that do not need sub-100ms precision.
/// </remarks>
public static class AccessTimeService
{
/// <summary>How often the cached time is refreshed.</summary>
public static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(100);
private static long _utime;
private static long _refs;
private static Timer? _timer;
private static readonly object _lock = new();
static AccessTimeService()
{
// Mirror Go's init(): nothing to pre-allocate in .NET.
}
/// <summary>
/// Registers a user. Starts the background timer when the first registrant calls this.
/// Each call to <see cref="Register"/> must be paired with a call to <see cref="Unregister"/>.
/// </summary>
public static void Register()
{
var v = Interlocked.Increment(ref _refs);
if (v == 1)
{
Interlocked.Exchange(ref _utime, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L);
lock (_lock)
{
_timer?.Dispose();
_timer = new Timer(_ =>
{
Interlocked.Exchange(ref _utime, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L);
}, null, TickInterval, TickInterval);
}
}
}
/// <summary>
/// Unregisters a user. Stops the background timer when the last registrant calls this.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when unregister is called more times than register.</exception>
public static void Unregister()
{
var v = Interlocked.Decrement(ref _refs);
if (v == 0)
{
lock (_lock)
{
_timer?.Dispose();
_timer = null;
}
}
else if (v < 0)
{
Interlocked.Exchange(ref _refs, 0);
throw new InvalidOperationException("ats: unbalanced unregister for access time state");
}
}
/// <summary>
/// Returns the last cached Unix nanosecond timestamp.
/// If no registrant is active, returns a fresh timestamp (avoids returning zero).
/// </summary>
public static long AccessTime()
{
var v = Interlocked.Read(ref _utime);
if (v == 0)
{
v = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
Interlocked.CompareExchange(ref _utime, v, 0);
v = Interlocked.Read(ref _utime);
}
return v;
}
/// <summary>
/// Resets all state. For testing only.
/// </summary>
internal static void Reset()
{
lock (_lock)
{
_timer?.Dispose();
_timer = null;
}
Interlocked.Exchange(ref _refs, 0);
Interlocked.Exchange(ref _utime, 0);
}
}

View File

@@ -0,0 +1,118 @@
// Copyright 2018-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/ring.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Internal;
// -------------------------------------------------------------------------
// Placeholder types — will be populated in session 08 (Client) and
// session 12 (Monitor/Events).
// -------------------------------------------------------------------------
/// <summary>
/// Subset of client connection info exposed on the monitoring endpoint (/connz).
/// Full implementation is in session 12 (monitor.go).
/// </summary>
public class ConnInfo { }
/// <summary>
/// Subscription detail for the monitoring endpoint.
/// Full implementation is in session 12 (monitor.go).
/// </summary>
public class SubDetail { }
// -------------------------------------------------------------------------
// ClosedClient / ClosedRingBuffer (ring.go)
// -------------------------------------------------------------------------
/// <summary>
/// Wraps connection info with optional items for the /connz endpoint.
/// Mirrors <c>closedClient</c> in server/ring.go.
/// </summary>
public sealed class ClosedClient
{
public ConnInfo Info { get; init; } = new();
public IReadOnlyList<SubDetail> Subs { get; init; } = [];
public string User { get; init; } = string.Empty;
public string Account { get; init; } = string.Empty;
}
/// <summary>
/// Fixed-size ring buffer that retains the most recent closed connections,
/// evicting the oldest when full.
/// Mirrors <c>closedRingBuffer</c> in server/ring.go.
/// </summary>
public sealed class ClosedRingBuffer
{
private ulong _total;
private readonly ClosedClient?[] _conns;
/// <summary>Creates a ring buffer that holds at most <paramref name="max"/> entries.</summary>
public ClosedRingBuffer(int max)
{
_conns = new ClosedClient?[max];
}
/// <summary>
/// Appends a closed connection, evicting the oldest if the buffer is full.
/// Mirrors <c>closedRingBuffer.append</c>.
/// </summary>
public void Append(ClosedClient cc)
{
_conns[Next()] = cc;
_total++;
}
// Index of the slot to write next — wraps around.
private int Next() => (int)(_total % (ulong)_conns.Length);
/// <summary>
/// Returns the number of entries currently stored (≤ capacity).
/// Mirrors <c>closedRingBuffer.len</c>.
/// </summary>
public int Len() =>
_total > (ulong)_conns.Length ? _conns.Length : (int)_total;
/// <summary>
/// Returns the total number of connections ever appended (not capped).
/// Mirrors <c>closedRingBuffer.totalConns</c>.
/// </summary>
public ulong TotalConns() => _total;
/// <summary>
/// Returns a chronologically ordered copy of the stored closed connections.
/// The caller may freely modify the returned array.
/// Mirrors <c>closedRingBuffer.closedClients</c>.
/// </summary>
public ClosedClient?[] ClosedClients()
{
var len = Len();
var dup = new ClosedClient?[len];
var head = Next();
if (_total <= (ulong)_conns.Length || head == 0)
{
Array.Copy(_conns, dup, len);
}
else
{
var fp = _conns.AsSpan(head); // oldest … end of array
var sp = _conns.AsSpan(0, head); // wrap-around … newest
fp.CopyTo(dup.AsSpan());
sp.CopyTo(dup.AsSpan(fp.Length));
}
return dup;
}
}

View File

@@ -0,0 +1,678 @@
// Copyright 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.
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
// Sublist is a routing mechanism to handle subject distribution and
// provides a facility to match subjects from published messages to
// interested subscribers. Subscribers can have wildcard subjects to
// match multiple published subjects.
/// <summary>
/// A value type used with <see cref="SimpleSublist"/> to track interest without
/// storing any associated data. Equivalent to Go's <c>struct{}</c>.
/// </summary>
public readonly struct EmptyStruct : IEquatable<EmptyStruct>
{
public static readonly EmptyStruct Value = default;
public bool Equals(EmptyStruct other) => true;
public override bool Equals(object? obj) => obj is EmptyStruct;
public override int GetHashCode() => 0;
public static bool operator ==(EmptyStruct left, EmptyStruct right) => true;
public static bool operator !=(EmptyStruct left, EmptyStruct right) => false;
}
/// <summary>
/// A thread-safe trie-based NATS subject routing list that efficiently stores and
/// retrieves subscriptions. Wildcards <c>*</c> (single-token) and <c>&gt;</c>
/// (full-wildcard) are supported.
/// </summary>
/// <typeparam name="T">The subscription value type. Must be non-null.</typeparam>
public class GenericSublist<T> where T : notnull
{
// Token separator and wildcard constants (mirrors Go's const block).
private const char Pwc = '*';
private const char Fwc = '>';
private const char Btsep = '.';
// -------------------------------------------------------------------------
// Public error singletons (mirrors Go's var block).
// -------------------------------------------------------------------------
/// <summary>Thrown when a subject is syntactically invalid.</summary>
public static readonly ArgumentException ErrInvalidSubject =
new("gsl: invalid subject");
/// <summary>Thrown when a subscription is not found during removal.</summary>
public static readonly KeyNotFoundException ErrNotFound =
new("gsl: no matches found");
/// <summary>Thrown when a value is already registered for the given subject.</summary>
public static readonly InvalidOperationException ErrAlreadyRegistered =
new("gsl: notification already registered");
// -------------------------------------------------------------------------
// Fields
// -------------------------------------------------------------------------
private readonly TrieLevel _root;
private uint _count;
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.NoRecursion);
// -------------------------------------------------------------------------
// Construction
// -------------------------------------------------------------------------
internal GenericSublist()
{
_root = new TrieLevel();
}
/// <summary>Creates a new <see cref="GenericSublist{T}"/>.</summary>
public static GenericSublist<T> NewSublist() => new();
/// <summary>Creates a new <see cref="SimpleSublist"/>.</summary>
public static SimpleSublist NewSimpleSublist() => new();
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/// <summary>Returns the total number of subscriptions stored.</summary>
public uint Count
{
get
{
_lock.EnterReadLock();
try { return _count; }
finally { _lock.ExitReadLock(); }
}
}
/// <summary>
/// Inserts a subscription into the trie.
/// Throws <see cref="ArgumentException"/> if <paramref name="subject"/> is invalid.
/// </summary>
public void Insert(string subject, T value)
{
_lock.EnterWriteLock();
try
{
InsertCore(subject, value);
}
finally
{
_lock.ExitWriteLock();
}
}
/// <summary>
/// Removes a subscription from the trie.
/// Throws <see cref="ArgumentException"/> if the subject is invalid, or
/// <see cref="KeyNotFoundException"/> if not found.
/// </summary>
public void Remove(string subject, T value)
{
_lock.EnterWriteLock();
try
{
RemoveCore(subject, value);
}
finally
{
_lock.ExitWriteLock();
}
}
/// <summary>
/// Calls <paramref name="action"/> for every value whose subscription matches
/// the literal <paramref name="subject"/>.
/// </summary>
public void Match(string subject, Action<T> action)
{
_lock.EnterReadLock();
try
{
var tokens = TokenizeForMatch(subject);
if (tokens == null) return;
MatchLevel(_root, tokens, 0, action);
}
finally
{
_lock.ExitReadLock();
}
}
/// <summary>
/// Calls <paramref name="action"/> for every value whose subscription matches
/// <paramref name="subject"/> supplied as a UTF-8 byte span.
/// </summary>
public void MatchBytes(ReadOnlySpan<byte> subject, Action<T> action)
{
Match(System.Text.Encoding.UTF8.GetString(subject), action);
}
/// <summary>
/// Returns <see langword="true"/> when at least one subscription matches
/// <paramref name="subject"/>.
/// </summary>
public bool HasInterest(string subject)
{
_lock.EnterReadLock();
try
{
var tokens = TokenizeForMatch(subject);
if (tokens == null) return false;
int dummy = 0;
return MatchLevelForAny(_root, tokens, 0, ref dummy);
}
finally
{
_lock.ExitReadLock();
}
}
/// <summary>
/// Returns the number of subscriptions that match <paramref name="subject"/>.
/// </summary>
public int NumInterest(string subject)
{
_lock.EnterReadLock();
try
{
var tokens = TokenizeForMatch(subject);
if (tokens == null) return 0;
int np = 0;
MatchLevelForAny(_root, tokens, 0, ref np);
return np;
}
finally
{
_lock.ExitReadLock();
}
}
/// <summary>
/// Returns <see langword="true"/> if the trie contains any subscription that
/// could match a subject whose tokens begin with the tokens of
/// <paramref name="subject"/>. Used for trie intersection checks.
/// </summary>
public bool HasInterestStartingIn(string subject)
{
_lock.EnterReadLock();
try
{
var tokens = TokenizeSubjectIntoSlice(subject);
return HasInterestStartingInLevel(_root, tokens, 0);
}
finally
{
_lock.ExitReadLock();
}
}
// -------------------------------------------------------------------------
// Internal helpers (accessible to tests in the same assembly).
// -------------------------------------------------------------------------
/// <summary>Returns the maximum depth of the trie. Used in tests.</summary>
internal int NumLevels() => VisitLevel(_root, 0);
// -------------------------------------------------------------------------
// Private: Insert core (lock must be held by caller)
// -------------------------------------------------------------------------
private void InsertCore(string subject, T value)
{
var sfwc = false; // seen full-wildcard token
TrieNode? n = null;
var l = _root;
// Iterate tokens split by '.' using index arithmetic to avoid allocations.
var start = 0;
while (start <= subject.Length)
{
// Find end of this token.
var end = subject.IndexOf(Btsep, start);
var isLast = end < 0;
if (isLast) end = subject.Length;
var tokenLen = end - start;
if (tokenLen == 0 || sfwc)
throw new ArgumentException(ErrInvalidSubject.Message);
if (tokenLen > 1)
{
var t = subject.Substring(start, tokenLen);
if (!l.Nodes.TryGetValue(t, out n))
{
n = new TrieNode();
l.Nodes[t] = n;
}
}
else
{
switch (subject[start])
{
case Pwc:
if (l.PwcNode == null) l.PwcNode = new TrieNode();
n = l.PwcNode;
break;
case Fwc:
if (l.FwcNode == null) l.FwcNode = new TrieNode();
n = l.FwcNode;
sfwc = true;
break;
default:
var t = subject.Substring(start, 1);
if (!l.Nodes.TryGetValue(t, out n))
{
n = new TrieNode();
l.Nodes[t] = n;
}
break;
}
}
n.Next ??= new TrieLevel();
l = n.Next;
if (isLast) break;
start = end + 1;
}
if (n == null)
throw new ArgumentException(ErrInvalidSubject.Message);
n.Subs[value] = subject;
_count++;
}
// -------------------------------------------------------------------------
// Private: Remove core (lock must be held by caller)
// -------------------------------------------------------------------------
private void RemoveCore(string subject, T value)
{
var sfwc = false;
var l = _root;
// We use a fixed-size stack-style array to track visited (level, node, token)
// triples so we can prune upward after removal. 32 is the same as Go's [32]lnt.
var levels = new LevelNodeToken[32];
var levelCount = 0;
TrieNode? n = null;
var start = 0;
while (start <= subject.Length)
{
var end = subject.IndexOf(Btsep, start);
var isLast = end < 0;
if (isLast) end = subject.Length;
var tokenLen = end - start;
if (tokenLen == 0 || sfwc)
throw new ArgumentException(ErrInvalidSubject.Message);
if (l == null!)
throw new KeyNotFoundException(ErrNotFound.Message);
var tokenStr = subject.Substring(start, tokenLen);
if (tokenLen > 1)
{
l.Nodes.TryGetValue(tokenStr, out n);
}
else
{
switch (tokenStr[0])
{
case Pwc:
n = l.PwcNode;
break;
case Fwc:
n = l.FwcNode;
sfwc = true;
break;
default:
l.Nodes.TryGetValue(tokenStr, out n);
break;
}
}
if (n != null)
{
if (levelCount < levels.Length)
levels[levelCount++] = new LevelNodeToken(l, n, tokenStr);
l = n.Next!;
}
else
{
l = null!;
}
if (isLast) break;
start = end + 1;
}
// Remove from the final node's subscription map.
if (!RemoveFromNode(n, value))
throw new KeyNotFoundException(ErrNotFound.Message);
_count--;
// Prune empty nodes upward.
for (var i = levelCount - 1; i >= 0; i--)
{
var (lv, nd, tk) = levels[i];
if (nd.IsEmpty())
lv.PruneNode(nd, tk);
}
}
private static bool RemoveFromNode(TrieNode? n, T value)
{
if (n == null) return false;
return n.Subs.Remove(value);
}
// -------------------------------------------------------------------------
// Private: matchLevel - recursive trie descent with callback
// Mirrors Go's matchLevel function exactly.
// -------------------------------------------------------------------------
private static void MatchLevel(TrieLevel? l, string[] tokens, int start, Action<T> action)
{
TrieNode? pwc = null;
TrieNode? n = null;
for (var i = start; i < tokens.Length; i++)
{
if (l == null) return;
// Full-wildcard at this level matches everything at/below.
if (l.FwcNode != null)
CallbacksForResults(l.FwcNode, action);
pwc = l.PwcNode;
if (pwc != null)
MatchLevel(pwc.Next, tokens, i + 1, action);
l.Nodes.TryGetValue(tokens[i], out n);
l = n?.Next;
}
// After consuming all tokens, emit subs from exact and pwc matches.
if (n != null)
CallbacksForResults(n, action);
if (pwc != null)
CallbacksForResults(pwc, action);
}
private static void CallbacksForResults(TrieNode n, Action<T> action)
{
foreach (var sub in n.Subs.Keys)
action(sub);
}
// -------------------------------------------------------------------------
// Private: matchLevelForAny - returns true on first match, counting via np
// Mirrors Go's matchLevelForAny function exactly.
// -------------------------------------------------------------------------
private static bool MatchLevelForAny(TrieLevel? l, string[] tokens, int start, ref int np)
{
TrieNode? pwc = null;
TrieNode? n = null;
for (var i = start; i < tokens.Length; i++)
{
if (l == null) return false;
if (l.FwcNode != null)
{
np += l.FwcNode.Subs.Count;
return true;
}
pwc = l.PwcNode;
if (pwc != null)
{
if (MatchLevelForAny(pwc.Next, tokens, i + 1, ref np))
return true;
}
l.Nodes.TryGetValue(tokens[i], out n);
l = n?.Next;
}
if (n != null)
{
np += n.Subs.Count;
if (n.Subs.Count > 0) return true;
}
if (pwc != null)
{
np += pwc.Subs.Count;
return pwc.Subs.Count > 0;
}
return false;
}
// -------------------------------------------------------------------------
// Private: hasInterestStartingIn - mirrors Go's hasInterestStartingIn
// -------------------------------------------------------------------------
private static bool HasInterestStartingInLevel(TrieLevel? l, string[] tokens, int start)
{
if (l == null) return false;
if (start >= tokens.Length) return true;
if (l.FwcNode != null) return true;
var found = false;
if (l.PwcNode != null)
found = HasInterestStartingInLevel(l.PwcNode.Next, tokens, start + 1);
if (!found && l.Nodes.TryGetValue(tokens[start], out var n))
found = HasInterestStartingInLevel(n.Next, tokens, start + 1);
return found;
}
// -------------------------------------------------------------------------
// Private: numLevels helper - mirrors Go's visitLevel
// -------------------------------------------------------------------------
private static int VisitLevel(TrieLevel? l, int depth)
{
if (l == null || l.NumNodes() == 0) return depth;
depth++;
var maxDepth = depth;
foreach (var n in l.Nodes.Values)
{
var d = VisitLevel(n.Next, depth);
if (d > maxDepth) maxDepth = d;
}
if (l.PwcNode != null)
{
var d = VisitLevel(l.PwcNode.Next, depth);
if (d > maxDepth) maxDepth = d;
}
if (l.FwcNode != null)
{
var d = VisitLevel(l.FwcNode.Next, depth);
if (d > maxDepth) maxDepth = d;
}
return maxDepth;
}
// -------------------------------------------------------------------------
// Private: tokenization helpers
// -------------------------------------------------------------------------
/// <summary>
/// Tokenizes a subject for match/hasInterest operations.
/// Returns <see langword="null"/> if the subject contains an empty token,
/// because an empty token can never match any subscription in the trie.
/// Mirrors Go's inline tokenization in <c>match()</c> and <c>hasInterest()</c>.
/// </summary>
private static string[]? TokenizeForMatch(string subject)
{
if (subject.Length == 0) return null;
var tokens = new List<string>(8);
var start = 0;
for (var i = 0; i < subject.Length; i++)
{
if (subject[i] == Btsep)
{
if (i - start == 0) return null; // empty token
tokens.Add(subject.Substring(start, i - start));
start = i + 1;
}
}
// Trailing separator produces empty last token.
if (start >= subject.Length) return null;
tokens.Add(subject.Substring(start));
return tokens.ToArray();
}
/// <summary>
/// Tokenizes a subject into a string array without validation.
/// Mirrors Go's <c>tokenizeSubjectIntoSlice</c>.
/// </summary>
private static string[] TokenizeSubjectIntoSlice(string subject)
{
var tokens = new List<string>(8);
var start = 0;
for (var i = 0; i < subject.Length; i++)
{
if (subject[i] == Btsep)
{
tokens.Add(subject.Substring(start, i - start));
start = i + 1;
}
}
tokens.Add(subject.Substring(start));
return tokens.ToArray();
}
// -------------------------------------------------------------------------
// Private: Trie node and level types
// -------------------------------------------------------------------------
/// <summary>
/// A trie node holding a subscription map and an optional link to the next level.
/// Mirrors Go's <c>node[T]</c>.
/// </summary>
private sealed class TrieNode
{
/// <summary>Maps subscription value → original subject string.</summary>
public readonly Dictionary<T, string> Subs = new();
/// <summary>The next trie level below this node, or null if at a leaf.</summary>
public TrieLevel? Next;
/// <summary>
/// Returns true when the node has no subscriptions and no live children.
/// Used during removal to decide whether to prune this node.
/// Mirrors Go's <c>node.isEmpty()</c>.
/// </summary>
public bool IsEmpty() => Subs.Count == 0 && (Next == null || Next.NumNodes() == 0);
}
/// <summary>
/// A trie level containing named child nodes and special wildcard slots.
/// Mirrors Go's <c>level[T]</c>.
/// </summary>
private sealed class TrieLevel
{
public readonly Dictionary<string, TrieNode> Nodes = new();
public TrieNode? PwcNode; // '*' single-token wildcard node
public TrieNode? FwcNode; // '>' full-wildcard node
/// <summary>
/// Returns the total count of live nodes at this level.
/// Mirrors Go's <c>level.numNodes()</c>.
/// </summary>
public int NumNodes()
{
var num = Nodes.Count;
if (PwcNode != null) num++;
if (FwcNode != null) num++;
return num;
}
/// <summary>
/// Removes an empty node from this level, using reference equality to
/// distinguish wildcard slots from named slots.
/// Mirrors Go's <c>level.pruneNode()</c>.
/// </summary>
public void PruneNode(TrieNode n, string token)
{
if (ReferenceEquals(n, FwcNode))
FwcNode = null;
else if (ReferenceEquals(n, PwcNode))
PwcNode = null;
else
Nodes.Remove(token);
}
}
/// <summary>
/// Tracks a (level, node, token) triple during removal for upward pruning.
/// Mirrors Go's <c>lnt[T]</c>.
/// </summary>
private readonly struct LevelNodeToken
{
public readonly TrieLevel Level;
public readonly TrieNode Node;
public readonly string Token;
public LevelNodeToken(TrieLevel level, TrieNode node, string token)
{
Level = level;
Node = node;
Token = token;
}
public void Deconstruct(out TrieLevel level, out TrieNode node, out string token)
{
level = Level;
node = Node;
token = Token;
}
}
}
/// <summary>
/// A lightweight sublist that tracks interest only, without storing any associated data.
/// Equivalent to Go's <c>SimpleSublist = GenericSublist[struct{}]</c>.
/// </summary>
public sealed class SimpleSublist : GenericSublist<EmptyStruct>
{
internal SimpleSublist() { }
}

View File

@@ -0,0 +1,263 @@
using System.Buffers.Binary;
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
/// <summary>
/// A time-based hash wheel for efficiently scheduling and expiring timer tasks keyed by sequence number.
/// Each slot covers a 1-second window; the wheel has 4096 slots (covering ~68 minutes before wrapping).
/// Not thread-safe.
/// </summary>
/// <remarks>
/// Mirrors the Go <c>thw.HashWheel</c> type. Timestamps are Unix nanoseconds (<see cref="long"/>).
/// </remarks>
public sealed class HashWheel
{
/// <summary>Slot width in nanoseconds (1 second).</summary>
private const long TickDuration = 1_000_000_000L;
private const int WheelBits = 12;
private const int WheelSize = 1 << WheelBits; // 4096
private const int WheelMask = WheelSize - 1;
private const int HeaderLen = 17; // 1 magic + 8 count + 8 highSeq
public static readonly Exception ErrTaskNotFound = new InvalidOperationException("thw: task not found");
public static readonly Exception ErrInvalidVersion = new InvalidDataException("thw: encoded version not known");
private readonly Slot?[] _wheel = new Slot?[WheelSize];
private long _lowest = long.MaxValue;
private ulong _count;
// --- Slot ---
private sealed class Slot
{
public readonly Dictionary<ulong, long> Entries = new();
public long Lowest = long.MaxValue;
}
/// <summary>Creates a new empty <see cref="HashWheel"/>.</summary>
public static HashWheel NewHashWheel() => new();
private static Slot NewSlot() => new();
private long GetPosition(long expires) => (expires / TickDuration) & WheelMask;
// --- Public API ---
/// <summary>Returns the number of tasks currently scheduled.</summary>
public ulong Count => _count;
/// <summary>Schedules a new timer task.</summary>
public void Add(ulong seq, long expires)
{
var pos = (int)GetPosition(expires);
_wheel[pos] ??= NewSlot();
var slot = _wheel[pos]!;
if (!slot.Entries.ContainsKey(seq))
_count++;
slot.Entries[seq] = expires;
if (expires < slot.Lowest)
{
slot.Lowest = expires;
if (expires < _lowest)
_lowest = expires;
}
}
/// <summary>Removes a timer task.</summary>
/// <exception cref="InvalidOperationException">Thrown (as <see cref="ErrTaskNotFound"/>) when not found.</exception>
public void Remove(ulong seq, long expires)
{
var pos = (int)GetPosition(expires);
var slot = _wheel[pos];
if (slot is null || !slot.Entries.ContainsKey(seq))
throw ErrTaskNotFound;
slot.Entries.Remove(seq);
_count--;
if (slot.Entries.Count == 0)
_wheel[pos] = null;
}
/// <summary>Updates the expiration time of an existing timer task.</summary>
public void Update(ulong seq, long oldExpires, long newExpires)
{
Remove(seq, oldExpires);
Add(seq, newExpires);
}
/// <summary>
/// Expires all tasks whose timestamp is &lt;= now. The callback receives each task;
/// if it returns <see langword="true"/> the task is removed, otherwise it is kept.
/// </summary>
public void ExpireTasks(Func<ulong, long, bool> callback)
{
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
ExpireTasksInternal(now, callback);
}
internal void ExpireTasksInternal(long ts, Func<ulong, long, bool> callback)
{
if (_lowest > ts)
return;
var globalLowest = long.MaxValue;
for (var pos = 0; pos < WheelSize; pos++)
{
var slot = _wheel[pos];
if (slot is null || slot.Lowest > ts)
{
if (slot is not null && slot.Lowest < globalLowest)
globalLowest = slot.Lowest;
continue;
}
var slotLowest = long.MaxValue;
// Snapshot keys to allow removal during iteration.
var keys = slot.Entries.Keys.ToArray();
foreach (var seq in keys)
{
var exp = slot.Entries[seq];
if (exp <= ts && callback(seq, exp))
{
slot.Entries.Remove(seq);
_count--;
continue;
}
if (exp < slotLowest)
slotLowest = exp;
}
if (slot.Entries.Count == 0)
{
_wheel[pos] = null;
}
else
{
slot.Lowest = slotLowest;
if (slotLowest < globalLowest)
globalLowest = slotLowest;
}
}
_lowest = globalLowest;
}
/// <summary>
/// Returns the earliest expiration timestamp before <paramref name="before"/>,
/// or <see cref="long.MaxValue"/> if none.
/// </summary>
public long GetNextExpiration(long before) =>
_lowest < before ? _lowest : long.MaxValue;
// --- Encode / Decode ---
/// <summary>
/// Serializes the wheel to a byte array. <paramref name="highSeq"/> is stored
/// in the header and returned by <see cref="Decode"/>.
/// </summary>
public byte[] Encode(ulong highSeq)
{
// Preallocate conservatively: header + up to 2 varints per entry.
var buf = new List<byte>(HeaderLen + (int)(_count * 16));
buf.Add(1); // magic version
AppendUint64LE(buf, _count);
AppendUint64LE(buf, highSeq);
foreach (var slot in _wheel)
{
if (slot is null)
continue;
foreach (var (seq, ts) in slot.Entries)
{
AppendVarint(buf, ts);
AppendUvarint(buf, seq);
}
}
return buf.ToArray();
}
/// <summary>
/// Replaces this wheel's contents with those from a binary snapshot.
/// Returns the <c>highSeq</c> stored in the header.
/// </summary>
public ulong Decode(ReadOnlySpan<byte> b)
{
if (b.Length < HeaderLen)
throw (InvalidDataException)ErrInvalidVersion;
if (b[0] != 1)
throw (InvalidDataException)ErrInvalidVersion;
// Reset wheel.
Array.Clear(_wheel);
_lowest = long.MaxValue;
_count = 0;
var count = BinaryPrimitives.ReadUInt64LittleEndian(b[1..]);
var stamp = BinaryPrimitives.ReadUInt64LittleEndian(b[9..]);
var pos = HeaderLen;
for (ulong i = 0; i < count; i++)
{
var ts = ReadVarint(b, ref pos);
var seq = ReadUvarint(b, ref pos);
Add(seq, ts);
}
return stamp;
}
// --- Encoding helpers ---
private static void AppendUint64LE(List<byte> buf, ulong v)
{
buf.Add((byte)v);
buf.Add((byte)(v >> 8));
buf.Add((byte)(v >> 16));
buf.Add((byte)(v >> 24));
buf.Add((byte)(v >> 32));
buf.Add((byte)(v >> 40));
buf.Add((byte)(v >> 48));
buf.Add((byte)(v >> 56));
}
private static void AppendVarint(List<byte> buf, long v)
{
// ZigZag encode like Go's binary.AppendVarint.
var uv = (ulong)((v << 1) ^ (v >> 63));
AppendUvarint(buf, uv);
}
private static void AppendUvarint(List<byte> buf, ulong v)
{
while (v >= 0x80)
{
buf.Add((byte)(v | 0x80));
v >>= 7;
}
buf.Add((byte)v);
}
private static long ReadVarint(ReadOnlySpan<byte> b, ref int pos)
{
var uv = ReadUvarint(b, ref pos);
var v = (long)(uv >> 1);
if ((uv & 1) != 0)
v = ~v;
return v;
}
private static ulong ReadUvarint(ReadOnlySpan<byte> b, ref int pos)
{
ulong x = 0;
int s = 0;
while (pos < b.Length)
{
var by = b[pos++];
x |= (ulong)(by & 0x7F) << s;
if ((by & 0x80) == 0)
return x;
s += 7;
}
throw new InvalidDataException("thw: unexpected EOF in varint");
}
}

View File

@@ -0,0 +1,631 @@
using System.Buffers.Binary;
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
/// <summary>
/// Memory and encoding-optimized set for storing unsigned 64-bit integers.
/// Implemented as an AVL tree where each node holds bitmasks for set membership.
/// Approximately 80-100x more memory-efficient than a <see cref="HashSet{T}"/>.
/// Not thread-safe.
/// </summary>
public sealed class SequenceSet
{
private const int BitsPerBucket = 64;
private const int NumBuckets = 32;
internal const int NumEntries = NumBuckets * BitsPerBucket; // 2048
private const byte MagicByte = 22;
private const byte CurrentVersion = 2;
private const int HdrLen = 2;
private const int MinLen = 2 + 8; // magic + version + num_nodes(4) + num_entries(4)
private Node? _root;
private int _size;
private int _nodes;
private bool _changed;
// --- Errors ---
public static readonly Exception ErrBadEncoding = new InvalidDataException("ss: bad encoding");
public static readonly Exception ErrBadVersion = new InvalidDataException("ss: bad version");
public static readonly Exception ErrSetNotEmpty = new InvalidOperationException("ss: set not empty");
// --- Internal access for testing ---
internal Node? Root => _root;
// --- Public API ---
/// <summary>Inserts a sequence number into the set. Tree is balanced inline.</summary>
public void Insert(ulong seq)
{
_root = Node.Insert(_root, seq, ref _changed, ref _nodes);
if (_changed)
{
_changed = false;
_size++;
}
}
/// <summary>Returns true if the sequence is a member of the set.</summary>
public bool Exists(ulong seq)
{
for (var n = _root; n != null;)
{
if (seq < n.Base)
{
n = n.Left;
}
else if (seq >= n.Base + NumEntries)
{
n = n.Right;
}
else
{
return n.ExistsBit(seq);
}
}
return false;
}
/// <summary>
/// Sets the initial minimum sequence when known. More effectively utilizes space.
/// The set must be empty.
/// </summary>
public void SetInitialMin(ulong min)
{
if (!IsEmpty)
throw (InvalidOperationException)ErrSetNotEmpty;
_root = new Node(min);
_nodes = 1;
}
/// <summary>
/// Removes the sequence from the set. Returns true if the sequence was present.
/// </summary>
public bool Delete(ulong seq)
{
if (_root == null) return false;
_root = Node.Delete(_root, seq, ref _changed, ref _nodes);
if (_changed)
{
_changed = false;
_size--;
if (_size == 0)
Empty();
return true;
}
return false;
}
/// <summary>Returns the number of items in the set.</summary>
public int Size => _size;
/// <summary>Returns the number of nodes in the AVL tree.</summary>
public int Nodes => _nodes;
/// <summary>Clears all items from the set.</summary>
public void Empty()
{
_root = null;
_size = 0;
_nodes = 0;
}
/// <summary>Returns true if the set contains no items.</summary>
public bool IsEmpty => _root == null;
/// <summary>
/// Invokes the callback for each item in ascending order.
/// Stops early if the callback returns false.
/// </summary>
public void Range(Func<ulong, bool> f) => Node.Iter(_root, f);
/// <summary>Returns the heights of the left and right subtrees of the root.</summary>
public (int Left, int Right) Heights()
{
if (_root == null) return (0, 0);
return (_root.Left?.Height ?? 0, _root.Right?.Height ?? 0);
}
/// <summary>Returns min, max, and count of set items.</summary>
public (ulong Min, ulong Max, ulong Count) State()
{
if (_root == null) return (0, 0, 0);
var (min, max) = MinMax();
return (min, max, (ulong)_size);
}
/// <summary>Returns the minimum and maximum values in the set.</summary>
public (ulong Min, ulong Max) MinMax()
{
if (_root == null) return (0, 0);
ulong min = 0;
for (var l = _root; l != null; l = l.Left)
if (l.Left == null) min = l.Min();
ulong max = 0;
for (var r = _root; r != null; r = r.Right)
if (r.Right == null) max = r.Max();
return (min, max);
}
/// <summary>Returns a deep clone of this set.</summary>
public SequenceSet Clone()
{
var css = new SequenceSet { _nodes = _nodes, _size = _size };
css._root = Node.Clone(_root);
return css;
}
/// <summary>Unions one or more sequence sets into this set.</summary>
public void Union(params SequenceSet[] sets)
{
foreach (var sa in sets)
{
Node.NodeIter(sa._root, n =>
{
for (var nb = 0; nb < NumBuckets; nb++)
{
var b = n.Bits[nb];
for (var pos = 0UL; b != 0; pos++)
{
if ((b & 1) == 1)
{
var seq = n.Base + ((ulong)nb * BitsPerBucket) + pos;
Insert(seq);
}
b >>= 1;
}
}
});
}
}
/// <summary>Returns the union of all given sets.</summary>
public static SequenceSet? UnionSets(params SequenceSet[] sets)
{
if (sets.Length == 0) return null;
// Clone the largest set first for efficiency.
Array.Sort(sets, (a, b) => b.Size.CompareTo(a.Size));
var ss = sets[0].Clone();
for (var i = 1; i < sets.Length; i++)
{
sets[i].Range(n =>
{
ss.Insert(n);
return true;
});
}
return ss;
}
/// <summary>Returns the number of bytes needed to encode this set.</summary>
public int EncodeLen() => MinLen + (Nodes * ((NumBuckets + 1) * 8 + 2));
/// <summary>
/// Encodes this set into a compact binary representation.
/// Reuses the provided buffer if it is large enough.
/// </summary>
public byte[] Encode(byte[]? buf)
{
var nn = Nodes;
var encLen = EncodeLen();
if (buf == null || buf.Length < encLen)
buf = new byte[encLen];
buf[0] = MagicByte;
buf[1] = CurrentVersion;
var i = HdrLen;
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(i), (uint)nn);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(i + 4), (uint)_size);
i += 8;
Node.NodeIter(_root, n =>
{
BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(i), n.Base);
i += 8;
foreach (var b in n.Bits)
{
BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(i), b);
i += 8;
}
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(i), (ushort)n.Height);
i += 2;
});
return buf[..i];
}
/// <summary>
/// Decodes a sequence set from the binary representation.
/// Returns the set and the number of bytes consumed.
/// Throws <see cref="InvalidDataException"/> on malformed input.
/// </summary>
public static (SequenceSet Set, int BytesRead) Decode(ReadOnlySpan<byte> buf)
{
if (buf.Length < MinLen || buf[0] != MagicByte)
throw (InvalidDataException)ErrBadEncoding;
return buf[1] switch
{
1 => Decodev1(buf),
2 => Decodev2(buf),
_ => throw (InvalidDataException)ErrBadVersion
};
}
// --- Internal tree helpers ---
/// <summary>Inserts a pre-built node directly into the tree (used during Decode).</summary>
internal void InsertNode(Node n)
{
_nodes++;
if (_root == null)
{
_root = n;
return;
}
for (var p = _root; p != null;)
{
if (n.Base < p.Base)
{
if (p.Left == null) { p.Left = n; return; }
p = p.Left;
}
else
{
if (p.Right == null) { p.Right = n; return; }
p = p.Right;
}
}
}
private static (SequenceSet Set, int BytesRead) Decodev2(ReadOnlySpan<byte> buf)
{
var index = 2;
var nn = (int)BinaryPrimitives.ReadUInt32LittleEndian(buf[index..]);
var sz = (int)BinaryPrimitives.ReadUInt32LittleEndian(buf[(index + 4)..]);
index += 8;
var expectedLen = MinLen + (nn * ((NumBuckets + 1) * 8 + 2));
if (buf.Length < expectedLen)
throw (InvalidDataException)ErrBadEncoding;
var ss = new SequenceSet { _size = sz };
var nodes = new Node[nn];
for (var i = 0; i < nn; i++)
{
var n = new Node(BinaryPrimitives.ReadUInt64LittleEndian(buf[index..]));
index += 8;
for (var bi = 0; bi < NumBuckets; bi++)
{
n.Bits[bi] = BinaryPrimitives.ReadUInt64LittleEndian(buf[index..]);
index += 8;
}
n.Height = (int)BinaryPrimitives.ReadUInt16LittleEndian(buf[index..]);
index += 2;
nodes[i] = n;
ss.InsertNode(n);
}
return (ss, index);
}
private static (SequenceSet Set, int BytesRead) Decodev1(ReadOnlySpan<byte> buf)
{
const int v1NumBuckets = 64;
var index = 2;
var nn = (int)BinaryPrimitives.ReadUInt32LittleEndian(buf[index..]);
var sz = (int)BinaryPrimitives.ReadUInt32LittleEndian(buf[(index + 4)..]);
index += 8;
var expectedLen = MinLen + (nn * ((v1NumBuckets + 1) * 8 + 2));
if (buf.Length < expectedLen)
throw (InvalidDataException)ErrBadEncoding;
var ss = new SequenceSet();
for (var i = 0; i < nn; i++)
{
var baseVal = BinaryPrimitives.ReadUInt64LittleEndian(buf[index..]);
index += 8;
for (var nb = 0UL; nb < v1NumBuckets; nb++)
{
var n = BinaryPrimitives.ReadUInt64LittleEndian(buf[index..]);
for (var pos = 0UL; n != 0; pos++)
{
if ((n & 1) == 1)
{
var seq = baseVal + (nb * BitsPerBucket) + pos;
ss.Insert(seq);
}
n >>= 1;
}
index += 8;
}
// Skip encoded height.
index += 2;
}
if (ss.Size != sz)
throw (InvalidDataException)ErrBadEncoding;
return (ss, index);
}
// -------------------------------------------------------------------------
// Internal Node class
// -------------------------------------------------------------------------
internal sealed class Node
{
public ulong Base;
public readonly ulong[] Bits = new ulong[NumBuckets];
public Node? Left;
public Node? Right;
public int Height;
public Node(ulong baseVal)
{
Base = baseVal;
Height = 1;
}
// Sets the bit for seq. seq must be within [Base, Base+NumEntries).
public void SetBit(ulong seq, ref bool inserted)
{
var offset = seq - Base;
var i = (int)(offset / BitsPerBucket);
var mask = 1UL << (int)(offset % BitsPerBucket);
if ((Bits[i] & mask) == 0)
{
Bits[i] |= mask;
inserted = true;
}
}
public bool ExistsBit(ulong seq)
{
var offset = seq - Base;
var i = (int)(offset / BitsPerBucket);
var mask = 1UL << (int)(offset % BitsPerBucket);
return (Bits[i] & mask) != 0;
}
// Clears the bit for seq. Returns true if the node is now empty.
public bool ClearBit(ulong seq, ref bool deleted)
{
var offset = seq - Base;
var i = (int)(offset / BitsPerBucket);
var mask = 1UL << (int)(offset % BitsPerBucket);
if ((Bits[i] & mask) != 0)
{
Bits[i] &= ~mask;
deleted = true;
}
foreach (var b in Bits)
if (b != 0) return false;
return true;
}
public ulong Min()
{
for (var i = 0; i < NumBuckets; i++)
{
if (Bits[i] != 0)
return Base + (ulong)(i * BitsPerBucket) + (ulong)System.Numerics.BitOperations.TrailingZeroCount(Bits[i]);
}
return 0;
}
public ulong Max()
{
for (var i = NumBuckets - 1; i >= 0; i--)
{
if (Bits[i] != 0)
return Base + (ulong)(i * BitsPerBucket) +
(ulong)(BitsPerBucket - System.Numerics.BitOperations.LeadingZeroCount(Bits[i] >> 1));
}
return 0;
}
// Static AVL helpers
public static int BalanceFactor(Node? n)
{
if (n == null) return 0;
return (n.Left?.Height ?? 0) - (n.Right?.Height ?? 0);
}
private static int MaxH(Node? n)
{
if (n == null) return 0;
return Math.Max(n.Left?.Height ?? 0, n.Right?.Height ?? 0);
}
public static Node Insert(Node? n, ulong seq, ref bool inserted, ref int nodes)
{
if (n == null)
{
var baseVal = (seq / NumEntries) * NumEntries;
var newNode = new Node(baseVal);
newNode.SetBit(seq, ref inserted);
nodes++;
return newNode;
}
if (seq < n.Base)
n.Left = Insert(n.Left, seq, ref inserted, ref nodes);
else if (seq >= n.Base + NumEntries)
n.Right = Insert(n.Right, seq, ref inserted, ref nodes);
else
n.SetBit(seq, ref inserted);
n.Height = MaxH(n) + 1;
var bf = BalanceFactor(n);
if (bf > 1)
{
if (BalanceFactor(n.Left) < 0)
n.Left = n.Left!.RotateLeft();
return n.RotateRight();
}
if (bf < -1)
{
if (BalanceFactor(n.Right) > 0)
n.Right = n.Right!.RotateRight();
return n.RotateLeft();
}
return n;
}
public static Node? Delete(Node? n, ulong seq, ref bool deleted, ref int nodes)
{
if (n == null) return null;
if (seq < n.Base)
n.Left = Delete(n.Left, seq, ref deleted, ref nodes);
else if (seq >= n.Base + NumEntries)
n.Right = Delete(n.Right, seq, ref deleted, ref nodes);
else if (n.ClearBit(seq, ref deleted))
{
nodes--;
if (n.Left == null)
n = n.Right;
else if (n.Right == null)
n = n.Left;
else
{
n.Right = n.Right.InsertNodePrev(n.Left);
n = n.Right;
}
}
if (n == null) return null;
n.Height = MaxH(n) + 1;
var bf = BalanceFactor(n);
if (bf > 1)
{
if (BalanceFactor(n.Left) < 0)
n.Left = n.Left!.RotateLeft();
return n.RotateRight();
}
if (bf < -1)
{
if (BalanceFactor(n.Right) > 0)
n.Right = n.Right!.RotateRight();
return n.RotateLeft();
}
return n;
}
private Node RotateLeft()
{
var r = Right;
if (r != null)
{
Right = r.Left;
r.Left = this;
Height = MaxH(this) + 1;
r.Height = MaxH(r) + 1;
}
else
{
Right = null;
Height = MaxH(this) + 1;
}
return r!;
}
private Node RotateRight()
{
var l = Left;
if (l != null)
{
Left = l.Right;
l.Right = this;
Height = MaxH(this) + 1;
l.Height = MaxH(l) + 1;
}
else
{
Left = null;
Height = MaxH(this) + 1;
}
return l!;
}
// Inserts nn into this subtree assuming nn.Base < all nodes in this subtree.
public Node InsertNodePrev(Node nn)
{
if (Left == null)
Left = nn;
else
Left = Left.InsertNodePrev(nn);
Height = MaxH(this) + 1;
var bf = BalanceFactor(this);
if (bf > 1)
{
if (BalanceFactor(Left) < 0)
Left = Left!.RotateLeft();
return RotateRight();
}
if (bf < -1)
{
if (BalanceFactor(Right) > 0)
Right = Right!.RotateRight();
return RotateLeft();
}
return this;
}
// Iterates nodes in tree order (pre-order: root → left → right).
public static void NodeIter(Node? n, Action<Node> f)
{
if (n == null) return;
f(n);
NodeIter(n.Left, f);
NodeIter(n.Right, f);
}
// Iterates items in ascending order (in-order traversal).
// Returns false if the callback returns false.
public static bool Iter(Node? n, Func<ulong, bool> f)
{
if (n == null) return true;
if (!Iter(n.Left, f)) return false;
for (var num = n.Base; num < n.Base + NumEntries; num++)
{
if (n.ExistsBit(num))
if (!f(num)) return false;
}
return Iter(n.Right, f);
}
public static Node? Clone(Node? src)
{
if (src == null) return null;
var n = new Node(src.Base) { Height = src.Height };
src.Bits.CopyTo(n.Bits, 0);
n.Left = Clone(src.Left);
n.Right = Clone(src.Right);
return n;
}
}
}

View File

@@ -0,0 +1,121 @@
// Copyright 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/sdm.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
/// <summary>
/// Per-sequence data tracked by <see cref="StreamDeletionMeta"/>.
/// Mirrors <c>SDMBySeq</c> in server/sdm.go.
/// </summary>
public readonly struct SdmBySeq
{
/// <summary>Whether this sequence was the last message for its subject.</summary>
public bool Last { get; init; }
/// <summary>Timestamp (nanoseconds UTC) when the removal/SDM was last proposed.</summary>
public long Ts { get; init; }
}
/// <summary>
/// Tracks pending subject delete markers (SDMs) and message removals for a stream.
/// Used by JetStream cluster consensus to avoid redundant proposals.
/// Mirrors <c>SDMMeta</c> in server/sdm.go.
/// </summary>
public sealed class StreamDeletionMeta
{
// Per-subject pending-count totals.
private readonly Dictionary<string, ulong> _totals = new(1);
// Per-sequence data keyed by sequence number.
private readonly Dictionary<ulong, SdmBySeq> _pending = new(1);
// -------------------------------------------------------------------------
// Header constants (forward-declared; populated in session 19 — JetStream).
// isSubjectDeleteMarker checks these header keys.
// -------------------------------------------------------------------------
// Mirrors JSMarkerReason header key (defined in jetstream.go).
internal const string HeaderJsMarkerReason = "Nats-Marker-Reason";
// Mirrors KVOperation header key (defined in jetstream.go).
internal const string HeaderKvOperation = "KV-Operation";
// Mirrors KVOperationValuePurge (defined in jetstream.go).
internal const string KvOperationValuePurge = "PURGE";
/// <summary>
/// Returns true when the given header block contains a subject delete marker
/// (either a JetStream marker or a KV purge operation).
/// Mirrors <c>isSubjectDeleteMarker</c> in server/sdm.go.
/// </summary>
public static bool IsSubjectDeleteMarker(ReadOnlySpan<byte> hdr)
{
// Simplified header scan: checks whether JSMarkerReason key is present
// or whether KV-Operation equals "PURGE".
// Full implementation depends on SliceHeader from session 08 (client.go).
// Until then this provides the correct contract.
var text = System.Text.Encoding.UTF8.GetString(hdr);
if (text.Contains(HeaderJsMarkerReason))
return true;
if (text.Contains($"{HeaderKvOperation}: {KvOperationValuePurge}"))
return true;
return false;
}
/// <summary>
/// Clears all tracked data.
/// Mirrors <c>SDMMeta.empty</c>.
/// </summary>
public void Empty()
{
_totals.Clear();
_pending.Clear();
}
/// <summary>
/// Tracks <paramref name="seq"/> as pending and returns whether it was
/// the last message for its subject. If the sequence is already tracked
/// the existing <c>Last</c> value is returned without modification.
/// Mirrors <c>SDMMeta.trackPending</c>.
/// </summary>
public bool TrackPending(ulong seq, string subj, bool last)
{
if (_pending.TryGetValue(seq, out var p))
return p.Last;
_pending[seq] = new SdmBySeq { Last = last, Ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L };
_totals[subj] = _totals.TryGetValue(subj, out var cnt) ? cnt + 1 : 1;
return last;
}
/// <summary>
/// Removes <paramref name="seq"/> and decrements the pending count for
/// <paramref name="subj"/>, deleting the subject entry when it reaches zero.
/// Mirrors <c>SDMMeta.removeSeqAndSubject</c>.
/// </summary>
public void RemoveSeqAndSubject(ulong seq, string subj)
{
if (!_pending.Remove(seq))
return;
if (_totals.TryGetValue(subj, out var msgs))
{
if (msgs <= 1)
_totals.Remove(subj);
else
_totals[subj] = msgs - 1;
}
}
}

View File

@@ -0,0 +1,488 @@
// Copyright 2023-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.
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
/// <summary>
/// An adaptive radix trie (ART) for storing subject information on literal NATS subjects.
/// Uses dynamic nodes (4/10/16/48/256 children), path compression, and lazy expansion.
/// Supports exact lookup, wildcard matching ('*' and '>'), and ordered/fast iteration.
/// Not thread-safe.
/// </summary>
public sealed class SubjectTree<T>
{
internal ISubjectTreeNode<T>? _root;
private int _size;
/// <summary>Returns the number of entries stored in the tree.</summary>
public int Size() => _size;
/// <summary>Returns true if the tree has no entries.</summary>
public bool Empty() => _size == 0;
/// <summary>Clears all entries from the tree.</summary>
public SubjectTree<T> Reset()
{
_root = null;
_size = 0;
return this;
}
/// <summary>
/// Inserts a value into the tree under the given subject.
/// If the subject already exists, returns the old value with updated=true.
/// Subjects containing byte 127 (the noPivot sentinel) are rejected silently.
/// </summary>
public (T? oldVal, bool updated) Insert(ReadOnlySpan<byte> subject, T value)
{
if (subject.IndexOf(SubjectTreeParts.NoPivot) >= 0)
return (default, false);
var subjectBytes = subject.ToArray();
var (old, updated) = DoInsert(ref _root, subjectBytes, value, 0);
if (!updated)
_size++;
return (old, updated);
}
/// <summary>
/// Finds the value stored at the given literal subject.
/// Returns (value, true) if found, (default, false) otherwise.
/// </summary>
public (T? val, bool found) Find(ReadOnlySpan<byte> subject)
{
var si = 0;
var n = _root;
var subjectBytes = subject.ToArray();
while (n != null)
{
if (n.IsLeaf)
{
var ln = (SubjectTreeLeaf<T>)n;
return ln.Match(subjectBytes.AsSpan(si))
? (ln.Value, true)
: (default, false);
}
var prefix = n.Prefix;
if (prefix.Length > 0)
{
var end = Math.Min(si + prefix.Length, subjectBytes.Length);
if (!subjectBytes.AsSpan(si, end - si).SequenceEqual(prefix.AsSpan(0, end - si)))
return (default, false);
si += prefix.Length;
}
var next = n.FindChild(SubjectTreeParts.Pivot(subjectBytes, si));
if (next == null) return (default, false);
n = next;
}
return (default, false);
}
/// <summary>
/// Deletes the entry at the given literal subject.
/// Returns (value, true) if deleted, (default, false) if not found.
/// </summary>
public (T? val, bool found) Delete(ReadOnlySpan<byte> subject)
{
if (_root == null || subject.IsEmpty) return (default, false);
var subjectBytes = subject.ToArray();
var (val, deleted) = DoDelete(ref _root, subjectBytes, 0);
if (deleted) _size--;
return (val, deleted);
}
/// <summary>
/// Matches all stored subjects against a filter that may contain wildcards ('*' and '>').
/// Invokes fn for each match. Return false from the callback to stop early.
/// </summary>
public void Match(ReadOnlySpan<byte> filter, Func<byte[], T, bool> fn)
{
if (_root == null || filter.IsEmpty || fn == null) return;
var parts = SubjectTreeParts.GenParts(filter.ToArray());
MatchNode(_root, parts, Array.Empty<byte>(), fn);
}
/// <summary>
/// Like Match but returns false if the callback stopped iteration early.
/// Returns true if matching ran to completion.
/// </summary>
public bool MatchUntil(ReadOnlySpan<byte> filter, Func<byte[], T, bool> fn)
{
if (_root == null || filter.IsEmpty || fn == null) return true;
var parts = SubjectTreeParts.GenParts(filter.ToArray());
return MatchNode(_root, parts, Array.Empty<byte>(), fn);
}
/// <summary>
/// Walks all entries in lexicographical order.
/// Return false from the callback to stop early.
/// </summary>
public void IterOrdered(Func<byte[], T, bool> fn)
{
if (_root == null || fn == null) return;
IterNode(_root, Array.Empty<byte>(), ordered: true, fn);
}
/// <summary>
/// Walks all entries in storage order (no ordering guarantee).
/// Return false from the callback to stop early.
/// </summary>
public void IterFast(Func<byte[], T, bool> fn)
{
if (_root == null || fn == null) return;
IterNode(_root, Array.Empty<byte>(), ordered: false, fn);
}
// -------------------------------------------------------------------------
// Internal recursive insert
// -------------------------------------------------------------------------
private static (T? old, bool updated) DoInsert(ref ISubjectTreeNode<T>? np, byte[] subject, T value, int si)
{
if (np == null)
{
np = new SubjectTreeLeaf<T>(subject, value);
return (default, false);
}
if (np.IsLeaf)
{
var ln = (SubjectTreeLeaf<T>)np;
if (ln.Match(subject.AsSpan(si)))
{
var oldVal = ln.Value;
ln.Value = value;
return (oldVal, true);
}
// Split the leaf: compute common prefix between existing suffix and new subject tail.
var cpi = SubjectTreeParts.CommonPrefixLen(ln.Suffix, subject.AsSpan(si));
var nn = new SubjectTreeNode4<T>(subject[si..(si + cpi)]);
ln.SetSuffix(ln.Suffix[cpi..]);
si += cpi;
var p = SubjectTreeParts.Pivot(ln.Suffix, 0);
if (cpi > 0 && si < subject.Length && p == subject[si])
{
// Same pivot after the split — recurse to separate further.
DoInsert(ref np, subject, value, si);
nn.AddChild(p, np!);
}
else
{
var nl = new SubjectTreeLeaf<T>(subject[si..], value);
nn.AddChild(SubjectTreeParts.Pivot(nl.Suffix, 0), nl);
nn.AddChild(SubjectTreeParts.Pivot(ln.Suffix, 0), ln);
}
np = nn;
return (default, false);
}
// Non-leaf node.
var prefix = np.Prefix;
if (prefix.Length > 0)
{
var cpi = SubjectTreeParts.CommonPrefixLen(prefix, subject.AsSpan(si));
if (cpi >= prefix.Length)
{
// Full prefix match: move past this node.
si += prefix.Length;
var pivotByte = SubjectTreeParts.Pivot(subject, si);
var existingChild = np.FindChild(pivotByte);
if (existingChild != null)
{
var before = existingChild;
var (old, upd) = DoInsert(ref existingChild, subject, value, si);
// Only re-register if the child reference changed identity (grew or split).
if (!ReferenceEquals(before, existingChild))
{
np.DeleteChild(pivotByte);
np.AddChild(pivotByte, existingChild!);
}
return (old, upd);
}
if (np.IsFull)
np = np.Grow();
np.AddChild(SubjectTreeParts.Pivot(subject, si), new SubjectTreeLeaf<T>(subject[si..], value));
return (default, false);
}
else
{
// Partial prefix match — insert a new node4 above the current node.
var newPrefix = subject[si..(si + cpi)];
si += cpi;
var splitNode = new SubjectTreeNode4<T>(newPrefix);
((SubjectTreeMeta<T>)np).SetPrefix(prefix[cpi..]);
// Use np.Prefix (updated) to get the correct pivot for the demoted node.
splitNode.AddChild(SubjectTreeParts.Pivot(np.Prefix, 0), np);
splitNode.AddChild(
SubjectTreeParts.Pivot(subject.AsSpan(si), 0),
new SubjectTreeLeaf<T>(subject[si..], value));
np = splitNode;
}
}
else
{
// No prefix on this node.
var pivotByte = SubjectTreeParts.Pivot(subject, si);
var existingChild = np.FindChild(pivotByte);
if (existingChild != null)
{
var before = existingChild;
var (old, upd) = DoInsert(ref existingChild, subject, value, si);
if (!ReferenceEquals(before, existingChild))
{
np.DeleteChild(pivotByte);
np.AddChild(pivotByte, existingChild!);
}
return (old, upd);
}
if (np.IsFull)
np = np.Grow();
np.AddChild(SubjectTreeParts.Pivot(subject, si), new SubjectTreeLeaf<T>(subject[si..], value));
}
return (default, false);
}
// -------------------------------------------------------------------------
// Internal recursive delete
// -------------------------------------------------------------------------
private static (T? val, bool deleted) DoDelete(ref ISubjectTreeNode<T>? np, byte[] subject, int si)
{
if (np == null || subject.Length == 0) return (default, false);
var n = np;
if (n.IsLeaf)
{
var ln = (SubjectTreeLeaf<T>)n;
if (ln.Match(subject.AsSpan(si)))
{
np = null;
return (ln.Value, true);
}
return (default, false);
}
// Check prefix.
var prefix = n.Prefix;
if (prefix.Length > 0)
{
if (subject.Length < si + prefix.Length)
return (default, false);
if (!subject.AsSpan(si, prefix.Length).SequenceEqual(prefix))
return (default, false);
si += prefix.Length;
}
var p = SubjectTreeParts.Pivot(subject, si);
var childNode = n.FindChild(p);
if (childNode == null) return (default, false);
if (childNode.IsLeaf)
{
var childLeaf = (SubjectTreeLeaf<T>)childNode;
if (childLeaf.Match(subject.AsSpan(si)))
{
n.DeleteChild(p);
TryShrink(ref np!, prefix);
return (childLeaf.Value, true);
}
return (default, false);
}
// Recurse into non-leaf child.
var (val, deleted) = DoDelete(ref childNode, subject, si);
if (deleted)
{
if (childNode == null)
{
// Child was nulled out — remove slot and try to shrink.
n.DeleteChild(p);
TryShrink(ref np!, prefix);
}
else
{
// Child changed identity — re-register.
n.DeleteChild(p);
n.AddChild(p, childNode);
}
}
return (val, deleted);
}
private static void TryShrink(ref ISubjectTreeNode<T> np, byte[] parentPrefix)
{
var shrunk = np.Shrink();
if (shrunk == null) return;
if (shrunk.IsLeaf)
{
var shrunkLeaf = (SubjectTreeLeaf<T>)shrunk;
if (parentPrefix.Length > 0)
shrunkLeaf.Suffix = [.. parentPrefix, .. shrunkLeaf.Suffix];
}
else if (parentPrefix.Length > 0)
{
((SubjectTreeMeta<T>)shrunk).SetPrefix([.. parentPrefix, .. shrunk.Prefix]);
}
np = shrunk;
}
// -------------------------------------------------------------------------
// Internal recursive wildcard match
// -------------------------------------------------------------------------
private static bool MatchNode(ISubjectTreeNode<T> n, byte[][] parts, byte[] pre, Func<byte[], T, bool> fn)
{
var hasFwc = parts.Length > 0 && parts[^1].Length == 1 && parts[^1][0] == SubjectTreeParts.Fwc;
while (n != null!)
{
var (nparts, matched) = n.MatchParts(parts);
if (!matched) return true;
if (n.IsLeaf)
{
if (nparts.Length == 0 || (hasFwc && nparts.Length == 1))
{
var ln = (SubjectTreeLeaf<T>)n;
if (!fn(ConcatBytes(pre, ln.Suffix), ln.Value)) return false;
}
return true;
}
// Append this node's prefix to the running accumulator.
var prefix = n.Prefix;
if (prefix.Length > 0)
pre = ConcatBytes(pre, prefix);
if (nparts.Length == 0 && !hasFwc)
{
// No parts remaining and no fwc — look for terminal matches.
var hasTermPwc = parts.Length > 0 && parts[^1].Length == 1 && parts[^1][0] == SubjectTreeParts.Pwc;
var termParts = hasTermPwc ? parts[^1..] : Array.Empty<byte[]>();
foreach (var cn in n.Children())
{
if (cn == null!) continue;
if (cn.IsLeaf)
{
var ln = (SubjectTreeLeaf<T>)cn;
if (ln.Suffix.Length == 0)
{
if (!fn(ConcatBytes(pre, ln.Suffix), ln.Value)) return false;
}
else if (hasTermPwc && Array.IndexOf(ln.Suffix, SubjectTreeParts.TSep) < 0)
{
if (!fn(ConcatBytes(pre, ln.Suffix), ln.Value)) return false;
}
}
else if (hasTermPwc)
{
if (!MatchNode(cn, termParts, pre, fn)) return false;
}
}
return true;
}
// Re-put the terminal fwc if nparts was exhausted by matching.
if (hasFwc && nparts.Length == 0)
nparts = parts[^1..];
var fp = nparts[0];
var pByte = SubjectTreeParts.Pivot(fp, 0);
if (fp.Length == 1 && (pByte == SubjectTreeParts.Pwc || pByte == SubjectTreeParts.Fwc))
{
// Wildcard part — iterate all children.
foreach (var cn in n.Children())
{
if (cn != null!)
{
if (!MatchNode(cn, nparts, pre, fn)) return false;
}
}
return true;
}
// Literal part — find specific child and loop.
var nextNode = n.FindChild(pByte);
if (nextNode == null) return true;
n = nextNode;
parts = nparts;
}
return true;
}
// -------------------------------------------------------------------------
// Internal iteration
// -------------------------------------------------------------------------
private static bool IterNode(ISubjectTreeNode<T> n, byte[] pre, bool ordered, Func<byte[], T, bool> fn)
{
if (n.IsLeaf)
{
var ln = (SubjectTreeLeaf<T>)n;
return fn(ConcatBytes(pre, ln.Suffix), ln.Value);
}
pre = ConcatBytes(pre, n.Prefix);
if (!ordered)
{
foreach (var cn in n.Children())
{
if (cn == null!) continue;
if (!IterNode(cn, pre, false, fn)) return false;
}
return true;
}
// Ordered: sort children by their path bytes lexicographically.
var children = n.Children().Where(c => c != null!).ToArray();
Array.Sort(children, static (a, b) => a.Path.AsSpan().SequenceCompareTo(b.Path.AsSpan()));
foreach (var cn in children)
{
if (!IterNode(cn, pre, true, fn)) return false;
}
return true;
}
// -------------------------------------------------------------------------
// Byte array helpers
// -------------------------------------------------------------------------
internal static byte[] ConcatBytes(byte[] a, byte[] b)
{
if (a.Length == 0) return b.Length == 0 ? Array.Empty<byte>() : b;
if (b.Length == 0) return a;
var result = new byte[a.Length + b.Length];
a.CopyTo(result, 0);
b.CopyTo(result, a.Length);
return result;
}
}

View File

@@ -0,0 +1,483 @@
// Copyright 2023-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.
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
// Internal node interface for the adaptive radix trie.
internal interface ISubjectTreeNode<T>
{
bool IsLeaf { get; }
byte[] Prefix { get; }
void AddChild(byte key, ISubjectTreeNode<T> child);
ISubjectTreeNode<T>? FindChild(byte key);
void DeleteChild(byte key);
bool IsFull { get; }
ISubjectTreeNode<T> Grow();
ISubjectTreeNode<T>? Shrink();
ISubjectTreeNode<T>[] Children();
int NumChildren { get; }
byte[] Path { get; }
(byte[][] remainingParts, bool matched) MatchParts(byte[][] parts);
string Kind { get; }
}
// Base class for non-leaf nodes, holding prefix and child count.
internal abstract class SubjectTreeMeta<T> : ISubjectTreeNode<T>
{
protected byte[] _prefix;
protected int _size;
protected SubjectTreeMeta(byte[] prefix)
{
_prefix = SubjectTreeParts.CopyBytes(prefix);
}
public bool IsLeaf => false;
public byte[] Prefix => _prefix;
public int NumChildren => _size;
public byte[] Path => _prefix;
public void SetPrefix(byte[] prefix)
{
_prefix = SubjectTreeParts.CopyBytes(prefix);
}
public (byte[][] remainingParts, bool matched) MatchParts(byte[][] parts)
=> SubjectTreeParts.MatchParts(parts, _prefix);
public abstract void AddChild(byte key, ISubjectTreeNode<T> child);
public abstract ISubjectTreeNode<T>? FindChild(byte key);
public abstract void DeleteChild(byte key);
public abstract bool IsFull { get; }
public abstract ISubjectTreeNode<T> Grow();
public abstract ISubjectTreeNode<T>? Shrink();
public abstract ISubjectTreeNode<T>[] Children();
public abstract string Kind { get; }
}
// Leaf node storing the terminal value plus a suffix byte[].
internal sealed class SubjectTreeLeaf<T> : ISubjectTreeNode<T>
{
public T Value;
public byte[] Suffix;
public SubjectTreeLeaf(byte[] suffix, T value)
{
Suffix = SubjectTreeParts.CopyBytes(suffix);
Value = value;
}
public bool IsLeaf => true;
public byte[] Prefix => Array.Empty<byte>();
public int NumChildren => 0;
public byte[] Path => Suffix;
public string Kind => "LEAF";
public bool Match(ReadOnlySpan<byte> subject)
=> subject.SequenceEqual(Suffix);
public void SetSuffix(byte[] suffix)
=> Suffix = SubjectTreeParts.CopyBytes(suffix);
public bool IsFull => true;
public (byte[][] remainingParts, bool matched) MatchParts(byte[][] parts)
=> SubjectTreeParts.MatchParts(parts, Suffix);
// Leaf nodes do not support child operations.
public void AddChild(byte key, ISubjectTreeNode<T> child)
=> throw new InvalidOperationException("AddChild called on leaf");
public ISubjectTreeNode<T>? FindChild(byte key)
=> throw new InvalidOperationException("FindChild called on leaf");
public void DeleteChild(byte key)
=> throw new InvalidOperationException("DeleteChild called on leaf");
public ISubjectTreeNode<T> Grow()
=> throw new InvalidOperationException("Grow called on leaf");
public ISubjectTreeNode<T>? Shrink()
=> throw new InvalidOperationException("Shrink called on leaf");
public ISubjectTreeNode<T>[] Children()
=> Array.Empty<ISubjectTreeNode<T>>();
}
// Node with up to 4 children (keys + children arrays, unsorted).
internal sealed class SubjectTreeNode4<T> : SubjectTreeMeta<T>
{
private readonly byte[] _keys = new byte[4];
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[4];
public SubjectTreeNode4(byte[] prefix) : base(prefix) { }
public override string Kind => "NODE4";
public override void AddChild(byte key, ISubjectTreeNode<T> child)
{
if (_size >= 4) throw new InvalidOperationException("node4 full!");
_keys[_size] = key;
_children[_size] = child;
_size++;
}
public override ISubjectTreeNode<T>? FindChild(byte key)
{
for (var i = 0; i < _size; i++)
{
if (_keys[i] == key) return _children[i];
}
return null;
}
public override void DeleteChild(byte key)
{
for (var i = 0; i < _size; i++)
{
if (_keys[i] == key)
{
var last = _size - 1;
if (i < last)
{
_keys[i] = _keys[last];
_children[i] = _children[last];
}
_keys[last] = 0;
_children[last] = null;
_size--;
return;
}
}
}
public override bool IsFull => _size >= 4;
public override ISubjectTreeNode<T> Grow()
{
var nn = new SubjectTreeNode10<T>(_prefix);
for (var i = 0; i < 4; i++)
nn.AddChild(_keys[i], _children[i]!);
return nn;
}
public override ISubjectTreeNode<T>? Shrink()
{
if (_size == 1) return _children[0];
return null;
}
public override ISubjectTreeNode<T>[] Children()
{
var result = new ISubjectTreeNode<T>[_size];
for (var i = 0; i < _size; i++)
result[i] = _children[i]!;
return result;
}
// Internal access for tests.
internal byte GetKey(int index) => _keys[index];
internal ISubjectTreeNode<T>? GetChild(int index) => _children[index];
}
// Node with up to 10 children (for numeric token segments).
internal sealed class SubjectTreeNode10<T> : SubjectTreeMeta<T>
{
private readonly byte[] _keys = new byte[10];
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[10];
public SubjectTreeNode10(byte[] prefix) : base(prefix) { }
public override string Kind => "NODE10";
public override void AddChild(byte key, ISubjectTreeNode<T> child)
{
if (_size >= 10) throw new InvalidOperationException("node10 full!");
_keys[_size] = key;
_children[_size] = child;
_size++;
}
public override ISubjectTreeNode<T>? FindChild(byte key)
{
for (var i = 0; i < _size; i++)
{
if (_keys[i] == key) return _children[i];
}
return null;
}
public override void DeleteChild(byte key)
{
for (var i = 0; i < _size; i++)
{
if (_keys[i] == key)
{
var last = _size - 1;
if (i < last)
{
_keys[i] = _keys[last];
_children[i] = _children[last];
}
_keys[last] = 0;
_children[last] = null;
_size--;
return;
}
}
}
public override bool IsFull => _size >= 10;
public override ISubjectTreeNode<T> Grow()
{
var nn = new SubjectTreeNode16<T>(_prefix);
for (var i = 0; i < _size; i++)
nn.AddChild(_keys[i], _children[i]!);
return nn;
}
public override ISubjectTreeNode<T>? Shrink()
{
if (_size > 4) return null;
var nn = new SubjectTreeNode4<T>(Array.Empty<byte>());
for (var i = 0; i < _size; i++)
nn.AddChild(_keys[i], _children[i]!);
return nn;
}
public override ISubjectTreeNode<T>[] Children()
{
var result = new ISubjectTreeNode<T>[_size];
for (var i = 0; i < _size; i++)
result[i] = _children[i]!;
return result;
}
}
// Node with up to 16 children.
internal sealed class SubjectTreeNode16<T> : SubjectTreeMeta<T>
{
private readonly byte[] _keys = new byte[16];
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[16];
public SubjectTreeNode16(byte[] prefix) : base(prefix) { }
public override string Kind => "NODE16";
public override void AddChild(byte key, ISubjectTreeNode<T> child)
{
if (_size >= 16) throw new InvalidOperationException("node16 full!");
_keys[_size] = key;
_children[_size] = child;
_size++;
}
public override ISubjectTreeNode<T>? FindChild(byte key)
{
for (var i = 0; i < _size; i++)
{
if (_keys[i] == key) return _children[i];
}
return null;
}
public override void DeleteChild(byte key)
{
for (var i = 0; i < _size; i++)
{
if (_keys[i] == key)
{
var last = _size - 1;
if (i < last)
{
_keys[i] = _keys[last];
_children[i] = _children[last];
}
_keys[last] = 0;
_children[last] = null;
_size--;
return;
}
}
}
public override bool IsFull => _size >= 16;
public override ISubjectTreeNode<T> Grow()
{
var nn = new SubjectTreeNode48<T>(_prefix);
for (var i = 0; i < _size; i++)
nn.AddChild(_keys[i], _children[i]!);
return nn;
}
public override ISubjectTreeNode<T>? Shrink()
{
if (_size > 10) return null;
var nn = new SubjectTreeNode10<T>(Array.Empty<byte>());
for (var i = 0; i < _size; i++)
nn.AddChild(_keys[i], _children[i]!);
return nn;
}
public override ISubjectTreeNode<T>[] Children()
{
var result = new ISubjectTreeNode<T>[_size];
for (var i = 0; i < _size; i++)
result[i] = _children[i]!;
return result;
}
}
// Node with up to 48 children, using a 256-byte key index (1-indexed, 0 means empty).
internal sealed class SubjectTreeNode48<T> : SubjectTreeMeta<T>
{
// _keyIndex[byte] = 1-based index into _children; 0 means no entry.
private readonly byte[] _keyIndex = new byte[256];
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[48];
public SubjectTreeNode48(byte[] prefix) : base(prefix) { }
public override string Kind => "NODE48";
public override void AddChild(byte key, ISubjectTreeNode<T> child)
{
if (_size >= 48) throw new InvalidOperationException("node48 full!");
_children[_size] = child;
_keyIndex[key] = (byte)(_size + 1); // 1-indexed
_size++;
}
public override ISubjectTreeNode<T>? FindChild(byte key)
{
var i = _keyIndex[key];
if (i == 0) return null;
return _children[i - 1];
}
public override void DeleteChild(byte key)
{
var i = _keyIndex[key];
if (i == 0) return;
i--; // Convert from 1-indexed
var last = _size - 1;
if (i < last)
{
_children[i] = _children[last];
// Find which key index points to 'last' and redirect it to 'i'.
for (var ic = 0; ic < 256; ic++)
{
if (_keyIndex[ic] == last + 1)
{
_keyIndex[ic] = (byte)(i + 1);
break;
}
}
}
_children[last] = null;
_keyIndex[key] = 0;
_size--;
}
public override bool IsFull => _size >= 48;
public override ISubjectTreeNode<T> Grow()
{
var nn = new SubjectTreeNode256<T>(_prefix);
for (var c = 0; c < 256; c++)
{
var i = _keyIndex[c];
if (i > 0)
nn.AddChild((byte)c, _children[i - 1]!);
}
return nn;
}
public override ISubjectTreeNode<T>? Shrink()
{
if (_size > 16) return null;
var nn = new SubjectTreeNode16<T>(Array.Empty<byte>());
for (var c = 0; c < 256; c++)
{
var i = _keyIndex[c];
if (i > 0)
nn.AddChild((byte)c, _children[i - 1]!);
}
return nn;
}
public override ISubjectTreeNode<T>[] Children()
{
var result = new ISubjectTreeNode<T>[_size];
var idx = 0;
for (var i = 0; i < _size; i++)
{
if (_children[i] != null)
result[idx++] = _children[i]!;
}
return result[..idx];
}
// Internal access for tests.
internal byte GetKeyIndex(int key) => _keyIndex[key];
internal ISubjectTreeNode<T>? GetChildAt(int index) => _children[index];
}
// Node with 256 children, indexed directly by byte value.
internal sealed class SubjectTreeNode256<T> : SubjectTreeMeta<T>
{
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[256];
public SubjectTreeNode256(byte[] prefix) : base(prefix) { }
public override string Kind => "NODE256";
public override void AddChild(byte key, ISubjectTreeNode<T> child)
{
_children[key] = child;
_size++;
}
public override ISubjectTreeNode<T>? FindChild(byte key)
=> _children[key];
public override void DeleteChild(byte key)
{
if (_children[key] != null)
{
_children[key] = null;
_size--;
}
}
public override bool IsFull => false;
public override ISubjectTreeNode<T> Grow()
=> throw new InvalidOperationException("Grow cannot be called on node256");
public override ISubjectTreeNode<T>? Shrink()
{
if (_size > 48) return null;
var nn = new SubjectTreeNode48<T>(Array.Empty<byte>());
for (var c = 0; c < 256; c++)
{
if (_children[c] != null)
nn.AddChild((byte)c, _children[c]!);
}
return nn;
}
public override ISubjectTreeNode<T>[] Children()
=> _children.Where(c => c != null).Select(c => c!).ToArray();
}

View File

@@ -0,0 +1,242 @@
// Copyright 2023-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.
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
/// <summary>
/// Utility methods for NATS subject matching, wildcard part decomposition,
/// common prefix computation, and byte manipulation used by SubjectTree.
/// </summary>
internal static class SubjectTreeParts
{
// NATS subject special bytes.
internal const byte Pwc = (byte)'*'; // single-token wildcard
internal const byte Fwc = (byte)'>'; // full wildcard (terminal)
internal const byte TSep = (byte)'.'; // token separator
// Sentinel pivot returned when subject position is past end.
internal const byte NoPivot = 127;
/// <summary>
/// Returns the pivot byte at <paramref name="pos"/> in <paramref name="subject"/>,
/// or <see cref="NoPivot"/> if the position is at or beyond the end.
/// </summary>
internal static byte Pivot(ReadOnlySpan<byte> subject, int pos)
=> pos >= subject.Length ? NoPivot : subject[pos];
/// <summary>
/// Returns the pivot byte at <paramref name="pos"/> in <paramref name="subject"/>,
/// or <see cref="NoPivot"/> if the position is at or beyond the end.
/// </summary>
internal static byte Pivot(byte[] subject, int pos)
=> pos >= subject.Length ? NoPivot : subject[pos];
/// <summary>
/// Computes the number of leading bytes that are equal between two spans.
/// </summary>
internal static int CommonPrefixLen(ReadOnlySpan<byte> s1, ReadOnlySpan<byte> s2)
{
var limit = Math.Min(s1.Length, s2.Length);
var i = 0;
while (i < limit && s1[i] == s2[i])
i++;
return i;
}
/// <summary>
/// Returns a copy of <paramref name="src"/>, or an empty array if src is empty.
/// </summary>
internal static byte[] CopyBytes(ReadOnlySpan<byte> src)
{
if (src.IsEmpty) return Array.Empty<byte>();
return src.ToArray();
}
/// <summary>
/// Returns a copy of <paramref name="src"/>, or an empty array if src is null or empty.
/// </summary>
internal static byte[] CopyBytes(byte[]? src)
{
if (src == null || src.Length == 0) return Array.Empty<byte>();
var dst = new byte[src.Length];
src.CopyTo(dst, 0);
return dst;
}
/// <summary>
/// Converts a byte array to a string using Latin-1 (ISO-8859-1) encoding,
/// which preserves a 1:1 byte-to-char mapping for all byte values 0-255.
/// </summary>
internal static string BytesToString(byte[] bytes)
{
if (bytes.Length == 0) return string.Empty;
return System.Text.Encoding.Latin1.GetString(bytes);
}
/// <summary>
/// Breaks a filter subject into parts separated by wildcards ('*' and '>').
/// Each literal segment between wildcards becomes one part; each wildcard
/// becomes its own single-byte part.
/// </summary>
internal static byte[][] GenParts(byte[] filter)
{
var parts = new List<byte[]>(8);
var start = 0;
var e = filter.Length - 1;
for (var i = 0; i < filter.Length; i++)
{
if (filter[i] == TSep)
{
// Check if next token is pwc (internal or terminal).
if (i < e && filter[i + 1] == Pwc &&
((i + 2 <= e && filter[i + 2] == TSep) || i + 1 == e))
{
if (i > start)
parts.Add(filter[start..(i + 1)]);
parts.Add(filter[(i + 1)..(i + 2)]);
i++; // skip pwc
if (i + 2 <= e)
i++; // skip next tsep from next part
start = i + 1;
}
else if (i < e && filter[i + 1] == Fwc && i + 1 == e)
{
if (i > start)
parts.Add(filter[start..(i + 1)]);
parts.Add(filter[(i + 1)..(i + 2)]);
i++; // skip fwc
start = i + 1;
}
}
else if (filter[i] == Pwc || filter[i] == Fwc)
{
// Wildcard must be preceded by tsep (or be at start).
var prev = i - 1;
if (prev >= 0 && filter[prev] != TSep)
continue;
// Wildcard must be at end or followed by tsep.
var next = i + 1;
if (next == e || (next < e && filter[next] != TSep))
continue;
// Full wildcard must be terminal.
if (filter[i] == Fwc && i < e)
break;
// Leading wildcard.
parts.Add(filter[i..(i + 1)]);
if (i + 1 <= e)
i++; // skip next tsep
start = i + 1;
}
}
if (start < filter.Length)
{
// Eat leading tsep if present.
if (filter[start] == TSep)
start++;
if (start < filter.Length)
parts.Add(filter[start..]);
}
return parts.ToArray();
}
/// <summary>
/// Matches parts against a fragment (prefix or suffix).
/// Returns the remaining parts and whether matching succeeded.
/// </summary>
internal static (byte[][] remainingParts, bool matched) MatchParts(byte[][] parts, byte[] frag)
{
var lf = frag.Length;
if (lf == 0) return (parts, true);
var si = 0;
var lpi = parts.Length - 1;
for (var i = 0; i < parts.Length; i++)
{
if (si >= lf)
return (parts[i..], true);
var part = parts[i];
var lp = part.Length;
// Check for wildcard placeholders.
if (lp == 1)
{
if (part[0] == Pwc)
{
// Find the next token separator.
var index = Array.IndexOf(frag, TSep, si);
if (index < 0)
{
// No tsep found.
if (i == lpi)
return (Array.Empty<byte[]>(), true);
return (parts[i..], true);
}
si = index + 1;
continue;
}
else if (part[0] == Fwc)
{
return (Array.Empty<byte[]>(), true);
}
}
var end = Math.Min(si + lp, lf);
// If part is larger than the remaining fragment, adjust.
var comparePart = part;
if (si + lp > end)
comparePart = part[..(end - si)];
if (!frag.AsSpan(si, end - si).SequenceEqual(comparePart))
return (parts, false);
// Fragment still has bytes left.
if (end < lf)
{
si = end;
continue;
}
// We matched a partial part.
if (end < si + lp)
{
if (end >= lf)
{
// Create a copy of parts with the current part trimmed.
var newParts = new byte[parts.Length][];
parts.CopyTo(newParts, 0);
newParts[i] = parts[i][(lf - si)..];
return (newParts[i..], true);
}
else
{
return (parts[(i + 1)..], true);
}
}
if (i == lpi)
return (Array.Empty<byte[]>(), true);
si += part.Length;
}
return (parts, false);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// A pointer that can be toggled between weak and strong references, allowing
/// the garbage collector to reclaim the target when weakened.
/// Mirrors the Go <c>elastic.Pointer[T]</c> type.
/// </summary>
/// <typeparam name="T">The type of the referenced object. Must be a reference type.</typeparam>
public sealed class ElasticPointer<T> where T : class
{
private WeakReference<T>? _weak;
private T? _strong;
/// <summary>
/// Creates a new <see cref="ElasticPointer{T}"/> holding a weak reference to <paramref name="value"/>.
/// </summary>
public static ElasticPointer<T> Make(T value)
{
return new ElasticPointer<T> { _weak = new WeakReference<T>(value) };
}
/// <summary>
/// Updates the target. If the pointer is currently strengthened, the strong reference is updated too.
/// </summary>
public void Set(T value)
{
_weak = new WeakReference<T>(value);
if (_strong != null)
_strong = value;
}
/// <summary>
/// Promotes to a strong reference, preventing the GC from collecting the target.
/// No-op if already strengthened or if the weak target has been collected.
/// </summary>
public void Strengthen()
{
if (_strong != null)
return;
if (_weak != null && _weak.TryGetTarget(out var target))
_strong = target;
}
/// <summary>
/// Reverts to a weak reference, allowing the GC to reclaim the target.
/// No-op if already weakened.
/// </summary>
public void Weaken()
{
_strong = null;
}
/// <summary>
/// Returns the target value, or <see langword="null"/> if the weak reference has been collected.
/// </summary>
public T? Value()
{
if (_strong != null)
return _strong;
if (_weak != null && _weak.TryGetTarget(out var target))
return target;
return null;
}
}

View File

@@ -0,0 +1,265 @@
// Copyright 2021-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/ipqueue.go in the NATS server Go source.
using System.Collections.Concurrent;
using System.Threading.Channels;
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// Error singletons for IpQueue limit violations.
/// Mirrors <c>errIPQLenLimitReached</c> and <c>errIPQSizeLimitReached</c>.
/// </summary>
public static class IpQueueErrors
{
public static readonly Exception LenLimitReached =
new InvalidOperationException("IPQ len limit reached");
public static readonly Exception SizeLimitReached =
new InvalidOperationException("IPQ size limit reached");
}
/// <summary>
/// Generic intra-process queue with a one-slot notification channel.
/// Mirrors <c>ipQueue[T]</c> in server/ipqueue.go.
/// </summary>
public sealed class IpQueue<T>
{
/// <summary>Default maximum size of the recycled backing-list capacity.</summary>
public const int DefaultMaxRecycleSize = 4 * 1024;
private long _inprogress;
private readonly object _lock = new();
// Backing list with a logical start position (mirrors slice + pos).
private List<T>? _elts;
private int _pos;
private ulong _sz;
private readonly string _name;
private readonly ConcurrentDictionary<string, object>? _registry;
// One-slot notification channel (mirrors chan struct{} with capacity 1).
private readonly Channel<bool> _ch;
// Single-slot list pool to amortise allocations.
private List<T>? _pooled;
// Options
/// <summary>Maximum list capacity to allow recycling.</summary>
public int MaxRecycleSize { get; }
private readonly Func<T, ulong>? _calc;
private readonly ulong _msz; // size limit
private readonly int _mlen; // length limit
/// <summary>Notification channel reader — wait on this to learn items were added.</summary>
public ChannelReader<bool> Ch => _ch.Reader;
/// <summary>
/// Creates a new queue, optionally registering it in <paramref name="registry"/>.
/// Mirrors <c>newIPQueue</c>.
/// </summary>
public IpQueue(
string name,
ConcurrentDictionary<string, object>? registry = null,
int maxRecycleSize = DefaultMaxRecycleSize,
Func<T, ulong>? sizeCalc = null,
ulong maxSize = 0,
int maxLen = 0)
{
MaxRecycleSize = maxRecycleSize;
_calc = sizeCalc;
_msz = maxSize;
_mlen = maxLen;
_name = name;
_registry = registry;
_ch = Channel.CreateBounded<bool>(new BoundedChannelOptions(1)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleReader = false,
SingleWriter = false,
});
registry?.TryAdd(name, this);
}
/// <summary>
/// Adds an element to the queue.
/// Returns the new logical length and an error if a limit was hit.
/// Mirrors <c>ipQueue.push</c>.
/// </summary>
public (int len, Exception? error) Push(T e)
{
bool shouldSignal;
int resultLen;
lock (_lock)
{
var l = (_elts?.Count ?? 0) - _pos;
if (_mlen > 0 && l == _mlen)
return (l, IpQueueErrors.LenLimitReached);
if (_calc != null)
{
var sz = _calc(e);
if (_msz > 0 && _sz + sz > _msz)
return (l, IpQueueErrors.SizeLimitReached);
_sz += sz;
}
if (_elts == null)
{
_elts = _pooled ?? new List<T>(32);
_pooled = null;
_pos = 0;
}
_elts.Add(e);
resultLen = _elts.Count - _pos;
shouldSignal = l == 0;
}
if (shouldSignal)
_ch.Writer.TryWrite(true);
return (resultLen, null);
}
/// <summary>
/// Returns all pending elements and empties the queue.
/// Increments the in-progress counter by the returned count.
/// Mirrors <c>ipQueue.pop</c>.
/// </summary>
public T[]? Pop()
{
lock (_lock)
{
if (_elts == null) return null;
var count = _elts.Count - _pos;
if (count == 0) return null;
var result = _pos == 0
? _elts.ToArray()
: _elts.GetRange(_pos, count).ToArray();
Interlocked.Add(ref _inprogress, result.Length);
_elts = null;
_pos = 0;
_sz = 0;
return result;
}
}
/// <summary>
/// Returns the first pending element without bulk-removing the rest.
/// Does NOT affect the in-progress counter.
/// Re-signals the notification channel if more elements remain.
/// Mirrors <c>ipQueue.popOne</c>.
/// </summary>
public (T value, bool ok) PopOne()
{
lock (_lock)
{
if (_elts == null || _elts.Count - _pos == 0)
return (default!, false);
var e = _elts[_pos];
var remaining = _elts.Count - _pos - 1;
if (_calc != null)
_sz -= _calc(e);
if (remaining > 0)
{
_pos++;
_ch.Writer.TryWrite(true); // re-signal: more items pending
}
else
{
// All consumed — try to pool the backing list.
if (_elts.Capacity <= MaxRecycleSize)
{
_elts.Clear();
_pooled = _elts;
}
_elts = null;
_pos = 0;
_sz = 0;
}
return (e, true);
}
}
/// <summary>
/// Returns the array obtained via <see cref="Pop"/> to the pool and
/// decrements the in-progress counter.
/// Mirrors <c>ipQueue.recycle</c>.
/// </summary>
public void Recycle(T[]? elts)
{
if (elts == null || elts.Length == 0) return;
Interlocked.Add(ref _inprogress, -elts.Length);
}
/// <summary>Returns the current logical queue length. Mirrors <c>ipQueue.len</c>.</summary>
public int Len()
{
lock (_lock) return (_elts?.Count ?? 0) - _pos;
}
/// <summary>
/// Returns the total calculated size (only meaningful when a size-calc function was provided).
/// Mirrors <c>ipQueue.size</c>.
/// </summary>
public ulong Size()
{
lock (_lock) return _sz;
}
/// <summary>
/// Empties the queue and consumes any pending notification signal.
/// Returns the number of items drained.
/// Mirrors <c>ipQueue.drain</c>.
/// </summary>
public int Drain()
{
lock (_lock)
{
var count = (_elts?.Count ?? 0) - _pos;
_elts = null;
_pos = 0;
_sz = 0;
_ch.Reader.TryRead(out _); // consume signal
return count;
}
}
/// <summary>
/// Returns the number of elements currently being processed (popped but not yet recycled).
/// Mirrors <c>ipQueue.inProgress</c>.
/// </summary>
public long InProgress() => Interlocked.Read(ref _inprogress);
/// <summary>
/// Removes this queue from the server registry.
/// Push/pop operations remain valid.
/// Mirrors <c>ipQueue.unregister</c>.
/// </summary>
public void Unregister() => _registry?.TryRemove(_name, out _);
}

View File

@@ -0,0 +1,431 @@
// Copyright 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/scheduler.go in the NATS server Go source.
using System.Buffers.Binary;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// Error for when we try to decode a binary-encoded message schedule with an unknown version number.
/// Mirrors <c>ErrMsgScheduleInvalidVersion</c>.
/// </summary>
public static class MsgSchedulingErrors
{
public static readonly Exception ErrMsgScheduleInvalidVersion =
new InvalidOperationException("msg scheduling: encoded version not known");
}
/// <summary>
/// A single scheduled message entry.
/// Mirrors the unnamed struct in the <c>schedules</c> map in scheduler.go.
/// </summary>
internal sealed class MsgSchedule
{
public ulong Seq;
public long Ts;
}
/// <summary>
/// Tracks per-subject scheduled messages using a hash wheel for TTL management.
/// Mirrors <c>MsgScheduling</c> in server/scheduler.go.
/// Note: <c>getScheduledMessages</c> is deferred to session 08/19 (requires JetStream types).
/// </summary>
public sealed class MsgScheduling
{
private const int HeaderLen = 17; // 1 magic + 2 × uint64
private readonly Action _run;
private readonly HashWheel _ttls;
private Timer? _timer;
// _running is set to true by the run callback when getScheduledMessages is active (session 08/19).
#pragma warning disable CS0649
private bool _running;
#pragma warning restore CS0649
private long _deadline;
private readonly Dictionary<string, MsgSchedule> _schedules;
private readonly Dictionary<ulong, string> _seqToSubj;
private readonly HashSet<string> _inflight;
/// <summary>
/// Creates a new <see cref="MsgScheduling"/> with the given callback.
/// Mirrors <c>newMsgScheduling</c>.
/// </summary>
public MsgScheduling(Action run)
{
_run = run;
_ttls = HashWheel.NewHashWheel();
_schedules = new Dictionary<string, MsgSchedule>();
_seqToSubj = new Dictionary<ulong, string>();
_inflight = new HashSet<string>();
}
/// <summary>
/// Adds a schedule entry and resets the timer.
/// Mirrors <c>MsgScheduling.add</c>.
/// </summary>
public void Add(ulong seq, string subj, long ts)
{
Init(seq, subj, ts);
ResetTimer();
}
/// <summary>
/// Inserts or updates the schedule entry for the given subject.
/// Mirrors <c>MsgScheduling.init</c>.
/// </summary>
public void Init(ulong seq, string subj, long ts)
{
if (_schedules.TryGetValue(subj, out var sched))
{
_seqToSubj.Remove(sched.Seq);
_ttls.Remove(sched.Seq, sched.Ts);
_ttls.Add(seq, ts);
sched.Ts = ts;
sched.Seq = seq;
}
else
{
_ttls.Add(seq, ts);
_schedules[subj] = new MsgSchedule { Seq = seq, Ts = ts };
}
_seqToSubj[seq] = subj;
_inflight.Remove(subj);
}
/// <summary>
/// Updates the timestamp for an existing schedule without changing the sequence.
/// Mirrors <c>MsgScheduling.update</c>.
/// </summary>
public void Update(string subj, long ts)
{
if (!_schedules.TryGetValue(subj, out var sched)) return;
_ttls.Remove(sched.Seq, sched.Ts);
_ttls.Add(sched.Seq, ts);
sched.Ts = ts;
_inflight.Remove(subj);
ResetTimer();
}
/// <summary>
/// Marks a subject as in-flight (being processed).
/// Mirrors <c>MsgScheduling.markInflight</c>.
/// </summary>
public void MarkInflight(string subj)
{
if (_schedules.ContainsKey(subj))
_inflight.Add(subj);
}
/// <summary>
/// Returns true if the subject is currently in-flight.
/// Mirrors <c>MsgScheduling.isInflight</c>.
/// </summary>
public bool IsInflight(string subj) => _inflight.Contains(subj);
/// <summary>
/// Removes the schedule entry for the given sequence number.
/// Mirrors <c>MsgScheduling.remove</c>.
/// </summary>
public void Remove(ulong seq)
{
if (!_seqToSubj.TryGetValue(seq, out var subj)) return;
_seqToSubj.Remove(seq);
_schedules.Remove(subj);
}
/// <summary>
/// Removes the schedule entry for the given subject.
/// Mirrors <c>MsgScheduling.removeSubject</c>.
/// </summary>
public void RemoveSubject(string subj)
{
if (!_schedules.TryGetValue(subj, out var sched)) return;
_ttls.Remove(sched.Seq, sched.Ts);
_schedules.Remove(subj);
_seqToSubj.Remove(sched.Seq);
}
/// <summary>
/// Clears all in-flight markers.
/// Mirrors <c>MsgScheduling.clearInflight</c>.
/// </summary>
public void ClearInflight() => _inflight.Clear();
/// <summary>
/// Arms or resets the internal timer to fire at the next scheduled expiration.
/// Mirrors <c>MsgScheduling.resetTimer</c>.
/// </summary>
public void ResetTimer()
{
if (_running) return;
var next = _ttls.GetNextExpiration(long.MaxValue);
if (next == long.MaxValue)
{
ClearTimer(ref _timer);
return;
}
// Convert nanosecond timestamp to DateTime (1 tick = 100 ns).
var nextTicks = DateTime.UnixEpoch.Ticks + next / 100L;
var nextUtc = new DateTime(nextTicks, DateTimeKind.Utc);
var fireIn = nextUtc - DateTime.UtcNow;
// Clamp minimum interval.
if (fireIn < TimeSpan.FromMilliseconds(250))
fireIn = TimeSpan.FromMilliseconds(250);
var deadline = DateTime.UtcNow.Ticks + fireIn.Ticks;
if (_deadline > 0 && deadline > _deadline) return;
_deadline = deadline;
if (_timer != null)
_timer.Change(fireIn, Timeout.InfiniteTimeSpan);
else
_timer = new Timer(_ => _run(), null, fireIn, Timeout.InfiniteTimeSpan);
}
// getScheduledMessages is deferred to session 08/19 — requires JetStream inMsg, StoreMsg types.
/// <summary>
/// Encodes the current schedule state to a binary snapshot.
/// Mirrors <c>MsgScheduling.encode</c>.
/// </summary>
public byte[] Encode(ulong highSeq)
{
var count = (ulong)_schedules.Count;
var buf = new List<byte>(HeaderLen + (int)(count * 20));
buf.Add(1); // magic version
AppendUInt64(buf, count);
AppendUInt64(buf, highSeq);
foreach (var (subj, sched) in _schedules)
{
var slen = (ushort)Math.Min((ulong)subj.Length, ushort.MaxValue);
AppendUInt16(buf, slen);
buf.AddRange(System.Text.Encoding.Latin1.GetBytes(subj[..slen]));
AppendVarint(buf, sched.Ts);
AppendUvarint(buf, sched.Seq);
}
return [.. buf];
}
/// <summary>
/// Decodes a binary snapshot into the current schedule.
/// Returns the high-sequence stamp or throws on error.
/// Mirrors <c>MsgScheduling.decode</c>.
/// </summary>
public (ulong highSeq, Exception? err) Decode(byte[] b)
{
if (b.Length < HeaderLen)
return (0, new System.IO.EndOfStreamException("short buffer"));
if (b[0] != 1)
return (0, MsgSchedulingErrors.ErrMsgScheduleInvalidVersion);
var count = BinaryPrimitives.ReadUInt64LittleEndian(b.AsSpan(1));
var stamp = BinaryPrimitives.ReadUInt64LittleEndian(b.AsSpan(9));
var offset = HeaderLen;
for (ulong i = 0; i < count; i++)
{
if (offset + 2 > b.Length)
return (0, new System.IO.EndOfStreamException("unexpected EOF"));
var sl = BinaryPrimitives.ReadUInt16LittleEndian(b.AsSpan(offset));
offset += 2;
if (offset + sl > b.Length)
return (0, new System.IO.EndOfStreamException("unexpected EOF"));
var subj = System.Text.Encoding.Latin1.GetString(b, offset, sl);
offset += sl;
var (ts, tn) = ReadVarint(b, offset);
if (tn < 0) return (0, new System.IO.EndOfStreamException("unexpected EOF"));
offset += tn;
var (seq, vn) = ReadUvarint(b, offset);
if (vn < 0) return (0, new System.IO.EndOfStreamException("unexpected EOF"));
offset += vn;
Init(seq, subj, ts);
}
return (stamp, null);
}
/// <summary>
/// Parses a message schedule pattern and returns the next fire time,
/// whether it repeats, and whether the pattern was valid.
/// Mirrors <c>parseMsgSchedule</c>.
/// </summary>
public static (DateTime next, bool repeat, bool ok) ParseMsgSchedule(string pattern, long ts)
{
if (pattern == string.Empty)
return (default, false, true);
if (pattern.StartsWith("@at ", StringComparison.Ordinal))
{
if (DateTime.TryParseExact(
pattern[4..],
"yyyy-MM-ddTHH:mm:ssK",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal,
out var t))
return (t, false, true);
return (default, false, false);
}
if (pattern.StartsWith("@every ", StringComparison.Ordinal))
{
if (!TryParseDuration(pattern[7..], out var dur))
return (default, false, false);
if (dur.TotalSeconds < 1)
return (default, false, false);
// Advance past a stale next tick the same way Go does.
var prev = DateTimeOffset.FromUnixTimeMilliseconds(ts / 1_000_000).UtcDateTime;
var next = RoundToSecond(prev).Add(dur);
var now = RoundToSecond(DateTime.UtcNow);
if (next < now)
next = now.Add(dur);
return (next, true, true);
}
return (default, false, false);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static void ClearTimer(ref Timer? timer)
{
var t = timer;
if (t == null) return;
t.Dispose();
timer = null;
}
private static DateTime RoundToSecond(DateTime dt) =>
new(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, DateTimeKind.Utc);
// Naive duration parser for strings like "1s", "500ms", "2m", "1h30m".
private static bool TryParseDuration(string s, out TimeSpan result)
{
result = default;
if (s.EndsWith("ms", StringComparison.Ordinal) &&
double.TryParse(s[..^2], out var ms))
{
result = TimeSpan.FromMilliseconds(ms);
return true;
}
if (s.EndsWith('s') && double.TryParse(s[..^1], out var sec))
{
result = TimeSpan.FromSeconds(sec);
return true;
}
if (s.EndsWith('m') && double.TryParse(s[..^1], out var min))
{
result = TimeSpan.FromMinutes(min);
return true;
}
if (s.EndsWith('h') && double.TryParse(s[..^1], out var hr))
{
result = TimeSpan.FromHours(hr);
return true;
}
// Try .NET TimeSpan.Parse as a fallback.
return TimeSpan.TryParse(s, out result);
}
// -------------------------------------------------------------------------
// Binary encoding helpers (mirrors encoding/binary in Go)
// -------------------------------------------------------------------------
private static void AppendUInt64(List<byte> buf, ulong v)
{
Span<byte> tmp = stackalloc byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(tmp, v);
buf.AddRange(tmp.ToArray());
}
private static void AppendUInt16(List<byte> buf, ushort v)
{
Span<byte> tmp = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(tmp, v);
buf.AddRange(tmp.ToArray());
}
/// <summary>Appends a zigzag-encoded signed varint (mirrors binary.AppendVarint).</summary>
private static void AppendVarint(List<byte> buf, long x)
{
var ux = (ulong)(x << 1);
if (x < 0) ux = ~ux;
AppendUvarint(buf, ux);
}
/// <summary>Appends an unsigned varint (mirrors binary.AppendUvarint).</summary>
private static void AppendUvarint(List<byte> buf, ulong x)
{
while (x >= 0x80)
{
buf.Add((byte)(x | 0x80));
x >>= 7;
}
buf.Add((byte)x);
}
/// <summary>
/// Reads a zigzag signed varint from <paramref name="b"/> starting at <paramref name="offset"/>.
/// Returns (value, bytesRead); bytesRead is negative on overflow.
/// </summary>
private static (long value, int n) ReadVarint(byte[] b, int offset)
{
var (ux, n) = ReadUvarint(b, offset);
var x = (long)(ux >> 1);
if ((ux & 1) != 0) x = ~x;
return (x, n);
}
/// <summary>
/// Reads an unsigned varint from <paramref name="b"/> starting at <paramref name="offset"/>.
/// Returns (value, bytesRead); bytesRead is negative on overflow.
/// </summary>
private static (ulong value, int n) ReadUvarint(byte[] b, int offset)
{
ulong x = 0;
var s = 0;
for (var i = offset; i < b.Length; i++)
{
var by = b[i];
if (i - offset == 10) return (0, -(i - offset + 1)); // overflow
if (by < 0x80)
{
if (i - offset == 9 && by > 1) return (0, -(i - offset + 1));
return (x | ((ulong)by << s), i - offset + 1);
}
x |= (ulong)(by & 0x7F) << s;
s += 7;
}
return (0, 0); // short buffer
}
}

View File

@@ -0,0 +1,187 @@
// 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/log.go in the NATS server Go source.
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// NATS server Logger interface.
/// Mirrors the Go <c>Logger</c> interface in log.go.
/// In .NET we bridge to <see cref="ILogger"/> from Microsoft.Extensions.Logging.
/// </summary>
public interface INatsLogger
{
void Noticef(string format, params object[] args);
void Warnf(string format, params object[] args);
void Fatalf(string format, params object[] args);
void Errorf(string format, params object[] args);
void Debugf(string format, params object[] args);
void Tracef(string format, params object[] args);
}
/// <summary>
/// Server logging state. Encapsulates the logger, debug/trace flags, and rate-limiting.
/// Mirrors the logging fields of Go's <c>Server</c> struct (logging struct + rateLimitLogging sync.Map).
/// </summary>
public sealed class ServerLogging
{
private readonly object _lock = new();
private INatsLogger? _logger;
private int _debug;
private int _trace;
private int _traceSysAcc;
private readonly ConcurrentDictionary<string, DateTime> _rateLimitMap = new();
/// <summary>Gets the current logger (thread-safe).</summary>
public INatsLogger? GetLogger()
{
lock (_lock) return _logger;
}
/// <summary>
/// Sets the logger with debug/trace flags.
/// Mirrors <c>Server.SetLoggerV2</c>.
/// </summary>
public void SetLoggerV2(INatsLogger? logger, bool debugFlag, bool traceFlag, bool sysTrace)
{
Interlocked.Exchange(ref _debug, debugFlag ? 1 : 0);
Interlocked.Exchange(ref _trace, traceFlag ? 1 : 0);
Interlocked.Exchange(ref _traceSysAcc, sysTrace ? 1 : 0);
lock (_lock)
{
if (_logger is IDisposable disposable)
disposable.Dispose();
_logger = logger;
}
}
/// <summary>
/// Sets the logger. Mirrors <c>Server.SetLogger</c>.
/// </summary>
public void SetLogger(INatsLogger? logger, bool debugFlag, bool traceFlag) =>
SetLoggerV2(logger, debugFlag, traceFlag, false);
public bool IsDebug => Interlocked.CompareExchange(ref _debug, 0, 0) != 0;
public bool IsTrace => Interlocked.CompareExchange(ref _trace, 0, 0) != 0;
public bool IsTraceSysAcc => Interlocked.CompareExchange(ref _traceSysAcc, 0, 0) != 0;
/// <summary>Executes a log call under the read lock.</summary>
public void ExecuteLogCall(Action<INatsLogger> action)
{
INatsLogger? logger;
lock (_lock) logger = _logger;
if (logger == null) return;
action(logger);
}
// ---- Convenience methods ----
public void Noticef(string format, params object[] args) =>
ExecuteLogCall(l => l.Noticef(format, args));
public void Errorf(string format, params object[] args) =>
ExecuteLogCall(l => l.Errorf(format, args));
public void Errors(object scope, Exception e) =>
ExecuteLogCall(l => l.Errorf("{0} - {1}", scope, e.Message));
public void Errorc(string ctx, Exception e) =>
ExecuteLogCall(l => l.Errorf("{0}: {1}", ctx, e.Message));
public void Errorsc(object scope, string ctx, Exception e) =>
ExecuteLogCall(l => l.Errorf("{0} - {1}: {2}", scope, ctx, e.Message));
public void Warnf(string format, params object[] args) =>
ExecuteLogCall(l => l.Warnf(format, args));
public void Fatalf(string format, params object[] args) =>
ExecuteLogCall(l => l.Fatalf(format, args));
public void Debugf(string format, params object[] args)
{
if (!IsDebug) return;
ExecuteLogCall(l => l.Debugf(format, args));
}
public void Tracef(string format, params object[] args)
{
if (!IsTrace) return;
ExecuteLogCall(l => l.Tracef(format, args));
}
/// <summary>
/// Rate-limited warning log. Only the first occurrence of each formatted statement is logged.
/// Mirrors <c>Server.RateLimitWarnf</c>.
/// </summary>
public void RateLimitWarnf(string format, params object[] args)
{
var statement = string.Format(format, args);
if (!_rateLimitMap.TryAdd(statement, DateTime.UtcNow)) return;
Warnf("{0}", statement);
}
/// <summary>
/// Rate-limited debug log. Only the first occurrence of each formatted statement is logged.
/// Mirrors <c>Server.RateLimitDebugf</c>.
/// </summary>
public void RateLimitDebugf(string format, params object[] args)
{
var statement = string.Format(format, args);
if (!_rateLimitMap.TryAdd(statement, DateTime.UtcNow)) return;
Debugf("{0}", statement);
}
/// <summary>
/// Rate-limited format warning. Only the first occurrence of each format string is logged.
/// Mirrors <c>Server.rateLimitFormatWarnf</c>.
/// </summary>
internal void RateLimitFormatWarnf(string format, params object[] args)
{
if (!_rateLimitMap.TryAdd(format, DateTime.UtcNow)) return;
var statement = string.Format(format, args);
Warnf("{0}", statement);
}
}
/// <summary>
/// Adapter that bridges <see cref="INatsLogger"/> to <see cref="ILogger"/>.
/// </summary>
public sealed class MicrosoftLoggerAdapter : INatsLogger
{
private readonly ILogger _logger;
public MicrosoftLoggerAdapter(ILogger logger) => _logger = logger;
public void Noticef(string format, params object[] args) =>
_logger.LogInformation(format, args);
public void Warnf(string format, params object[] args) =>
_logger.LogWarning(format, args);
public void Fatalf(string format, params object[] args) =>
_logger.LogCritical(format, args);
public void Errorf(string format, params object[] args) =>
_logger.LogError(format, args);
public void Debugf(string format, params object[] args) =>
_logger.LogDebug(format, args);
public void Tracef(string format, params object[] args) =>
_logger.LogTrace(format, args);
}

View File

@@ -0,0 +1,106 @@
using System.Diagnostics;
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// Provides cross-platform process CPU and memory usage statistics.
/// Mirrors the Go <c>pse</c> (Process Status Emulation) package, replacing
/// per-platform implementations (rusage, /proc/stat, PDH) with
/// <see cref="System.Diagnostics.Process"/>.
/// </summary>
public static class ProcessStatsProvider
{
private static readonly Process _self = Process.GetCurrentProcess();
private static readonly int _processorCount = Environment.ProcessorCount;
private static readonly object _lock = new();
private static TimeSpan _lastCpuTime;
private static DateTime _lastSampleTime;
private static double _cachedPcpu;
private static long _cachedRss;
private static long _cachedVss;
static ProcessStatsProvider()
{
UpdateUsage();
StartPeriodicSampling();
}
/// <summary>
/// Returns the current process CPU percentage, RSS (bytes), and VSS (bytes).
/// Values are refreshed approximately every second by a background timer.
/// </summary>
/// <param name="pcpu">Percent CPU utilization (0100 × core count).</param>
/// <param name="rss">Resident set size in bytes.</param>
/// <param name="vss">Virtual memory size in bytes.</param>
public static void ProcUsage(out double pcpu, out long rss, out long vss)
{
lock (_lock)
{
pcpu = _cachedPcpu;
rss = _cachedRss;
vss = _cachedVss;
}
}
private static void UpdateUsage()
{
try
{
_self.Refresh();
var now = DateTime.UtcNow;
var cpuTime = _self.TotalProcessorTime;
lock (_lock)
{
var elapsed = now - _lastSampleTime;
if (elapsed >= TimeSpan.FromMilliseconds(500))
{
var cpuDelta = (cpuTime - _lastCpuTime).TotalSeconds;
// Normalize against elapsed wall time.
// Result is 0100; does not multiply by ProcessorCount to match Go behaviour.
_cachedPcpu = elapsed.TotalSeconds > 0
? Math.Round(cpuDelta / elapsed.TotalSeconds * 1000.0) / 10.0
: 0;
_lastSampleTime = now;
_lastCpuTime = cpuTime;
}
_cachedRss = _self.WorkingSet64;
_cachedVss = _self.VirtualMemorySize64;
}
}
catch
{
// Suppress — diagnostics should never crash the server.
}
}
private static void StartPeriodicSampling()
{
var timer = new Timer(_ => UpdateUsage(), null,
dueTime: TimeSpan.FromSeconds(1),
period: TimeSpan.FromSeconds(1));
// Keep timer alive for the process lifetime.
GC.KeepAlive(timer);
}
// --- Windows PDH helpers (replaced by Process class in .NET) ---
// The following methods exist to satisfy the porting mapping but delegate
// to the cross-platform Process API above.
internal static string GetProcessImageName() =>
Path.GetFileNameWithoutExtension(Environment.ProcessPath ?? _self.ProcessName);
internal static void InitCounters()
{
// No-op: .NET Process class initializes lazily.
}
internal static double PdhOpenQuery() => 0; // Mapped to Process API.
internal static double PdhAddCounter() => 0;
internal static double PdhCollectQueryData() => 0;
internal static double PdhGetFormattedCounterArrayDouble() => 0;
internal static double GetCounterArrayData() => 0;
}

View File

@@ -0,0 +1,284 @@
// Copyright 2024 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/proto.go in the NATS server Go source.
// Inspired by https://github.com/protocolbuffers/protobuf-go/blob/master/encoding/protowire/wire.go
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// Low-level protobuf wire format helpers used internally for JetStream API encoding.
/// Mirrors server/proto.go.
/// </summary>
public static class ProtoWire
{
private static readonly Exception ErrInsufficient =
new InvalidOperationException("insufficient data to read a value");
private static readonly Exception ErrOverflow =
new InvalidOperationException("too much data for a value");
private static readonly Exception ErrInvalidFieldNumber =
new InvalidOperationException("invalid field number");
// -------------------------------------------------------------------------
// Field scanning
// -------------------------------------------------------------------------
/// <summary>
/// Reads a complete protobuf field (tag + value) from <paramref name="b"/>
/// and returns the field number, wire type, and total byte size consumed.
/// Mirrors <c>protoScanField</c>.
/// </summary>
public static (int num, int typ, int size, Exception? err) ScanField(ReadOnlySpan<byte> b)
{
var (num, typ, sizeTag, err) = ScanTag(b);
if (err != null) return (0, 0, 0, err);
var (sizeValue, err2) = ScanFieldValue(typ, b[sizeTag..]);
if (err2 != null) return (0, 0, 0, err2);
return (num, typ, sizeTag + sizeValue, null);
}
/// <summary>
/// Reads a protobuf tag varint and returns field number, wire type, and bytes consumed.
/// Mirrors <c>protoScanTag</c>.
/// </summary>
public static (int num, int typ, int size, Exception? err) ScanTag(ReadOnlySpan<byte> b)
{
var (tagint, size, err) = ScanVarint(b);
if (err != null) return (0, 0, 0, err);
if ((tagint >> 3) > int.MaxValue)
return (0, 0, 0, ErrInvalidFieldNumber);
var num = (int)(tagint >> 3);
if (num < 1)
return (0, 0, 0, ErrInvalidFieldNumber);
var typ = (int)(tagint & 7);
return (num, typ, size, null);
}
/// <summary>
/// Returns the byte count consumed by a field value with the given wire type.
/// Mirrors <c>protoScanFieldValue</c>.
/// </summary>
public static (int size, Exception? err) ScanFieldValue(int typ, ReadOnlySpan<byte> b)
{
switch (typ)
{
case 0: // varint
{
var (_, size, err) = ScanVarint(b);
return (size, err);
}
case 5: // fixed32
return (4, null);
case 1: // fixed64
return (8, null);
case 2: // length-delimited
{
var (size, err) = ScanBytes(b);
return (size, err);
}
default:
return (0, new InvalidOperationException($"unsupported type: {typ}"));
}
}
// -------------------------------------------------------------------------
// Varint decode
// -------------------------------------------------------------------------
/// <summary>
/// Decodes a protobuf varint from <paramref name="b"/>.
/// Returns (value, bytes_consumed, error).
/// Mirrors <c>protoScanVarint</c>.
/// </summary>
public static (ulong v, int size, Exception? err) ScanVarint(ReadOnlySpan<byte> b)
{
if (b.Length < 1) return (0, 0, ErrInsufficient);
ulong v = b[0];
if (v < 0x80) return (v, 1, null);
v -= 0x80;
if (b.Length < 2) return (0, 0, ErrInsufficient);
ulong y = b[1];
v += y << 7;
if (y < 0x80) return (v, 2, null);
v -= 0x80UL << 7;
if (b.Length < 3) return (0, 0, ErrInsufficient);
y = b[2];
v += y << 14;
if (y < 0x80) return (v, 3, null);
v -= 0x80UL << 14;
if (b.Length < 4) return (0, 0, ErrInsufficient);
y = b[3];
v += y << 21;
if (y < 0x80) return (v, 4, null);
v -= 0x80UL << 21;
if (b.Length < 5) return (0, 0, ErrInsufficient);
y = b[4];
v += y << 28;
if (y < 0x80) return (v, 5, null);
v -= 0x80UL << 28;
if (b.Length < 6) return (0, 0, ErrInsufficient);
y = b[5];
v += y << 35;
if (y < 0x80) return (v, 6, null);
v -= 0x80UL << 35;
if (b.Length < 7) return (0, 0, ErrInsufficient);
y = b[6];
v += y << 42;
if (y < 0x80) return (v, 7, null);
v -= 0x80UL << 42;
if (b.Length < 8) return (0, 0, ErrInsufficient);
y = b[7];
v += y << 49;
if (y < 0x80) return (v, 8, null);
v -= 0x80UL << 49;
if (b.Length < 9) return (0, 0, ErrInsufficient);
y = b[8];
v += y << 56;
if (y < 0x80) return (v, 9, null);
v -= 0x80UL << 56;
if (b.Length < 10) return (0, 0, ErrInsufficient);
y = b[9];
v += y << 63;
if (y < 2) return (v, 10, null);
return (0, 0, ErrOverflow);
}
// -------------------------------------------------------------------------
// Length-delimited decode
// -------------------------------------------------------------------------
/// <summary>
/// Returns the total byte count consumed by a length-delimited field
/// (length varint + content).
/// Mirrors <c>protoScanBytes</c>.
/// </summary>
public static (int size, Exception? err) ScanBytes(ReadOnlySpan<byte> b)
{
var (l, lenSize, err) = ScanVarint(b);
if (err != null) return (0, err);
if (l > (ulong)(b.Length - lenSize))
return (0, ErrInsufficient);
return (lenSize + (int)l, null);
}
// -------------------------------------------------------------------------
// Varint encode
// -------------------------------------------------------------------------
/// <summary>
/// Encodes a <see cref="ulong"/> as a protobuf varint.
/// Mirrors <c>protoEncodeVarint</c>.
/// </summary>
public static byte[] EncodeVarint(ulong v)
{
if (v < 1UL << 7)
return [(byte)v];
if (v < 1UL << 14)
return [(byte)((v >> 0) & 0x7F | 0x80), (byte)(v >> 7)];
if (v < 1UL << 21)
return [
(byte)((v >> 0) & 0x7F | 0x80),
(byte)((v >> 7) & 0x7F | 0x80),
(byte)(v >> 14)];
if (v < 1UL << 28)
return [
(byte)((v >> 0) & 0x7F | 0x80),
(byte)((v >> 7) & 0x7F | 0x80),
(byte)((v >> 14) & 0x7F | 0x80),
(byte)(v >> 21)];
if (v < 1UL << 35)
return [
(byte)((v >> 0) & 0x7F | 0x80),
(byte)((v >> 7) & 0x7F | 0x80),
(byte)((v >> 14) & 0x7F | 0x80),
(byte)((v >> 21) & 0x7F | 0x80),
(byte)(v >> 28)];
if (v < 1UL << 42)
return [
(byte)((v >> 0) & 0x7F | 0x80),
(byte)((v >> 7) & 0x7F | 0x80),
(byte)((v >> 14) & 0x7F | 0x80),
(byte)((v >> 21) & 0x7F | 0x80),
(byte)((v >> 28) & 0x7F | 0x80),
(byte)(v >> 35)];
if (v < 1UL << 49)
return [
(byte)((v >> 0) & 0x7F | 0x80),
(byte)((v >> 7) & 0x7F | 0x80),
(byte)((v >> 14) & 0x7F | 0x80),
(byte)((v >> 21) & 0x7F | 0x80),
(byte)((v >> 28) & 0x7F | 0x80),
(byte)((v >> 35) & 0x7F | 0x80),
(byte)(v >> 42)];
if (v < 1UL << 56)
return [
(byte)((v >> 0) & 0x7F | 0x80),
(byte)((v >> 7) & 0x7F | 0x80),
(byte)((v >> 14) & 0x7F | 0x80),
(byte)((v >> 21) & 0x7F | 0x80),
(byte)((v >> 28) & 0x7F | 0x80),
(byte)((v >> 35) & 0x7F | 0x80),
(byte)((v >> 42) & 0x7F | 0x80),
(byte)(v >> 49)];
if (v < 1UL << 63)
return [
(byte)((v >> 0) & 0x7F | 0x80),
(byte)((v >> 7) & 0x7F | 0x80),
(byte)((v >> 14) & 0x7F | 0x80),
(byte)((v >> 21) & 0x7F | 0x80),
(byte)((v >> 28) & 0x7F | 0x80),
(byte)((v >> 35) & 0x7F | 0x80),
(byte)((v >> 42) & 0x7F | 0x80),
(byte)((v >> 49) & 0x7F | 0x80),
(byte)(v >> 56)];
return [
(byte)((v >> 0) & 0x7F | 0x80),
(byte)((v >> 7) & 0x7F | 0x80),
(byte)((v >> 14) & 0x7F | 0x80),
(byte)((v >> 21) & 0x7F | 0x80),
(byte)((v >> 28) & 0x7F | 0x80),
(byte)((v >> 35) & 0x7F | 0x80),
(byte)((v >> 42) & 0x7F | 0x80),
(byte)((v >> 49) & 0x7F | 0x80),
(byte)((v >> 56) & 0x7F | 0x80),
1];
}
}

View File

@@ -0,0 +1,81 @@
// Copyright 2021-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/rate_counter.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// A sliding-window rate limiter that allows at most <c>limit</c> events
/// per <see cref="Interval"/> window.
/// Mirrors <c>rateCounter</c> in server/rate_counter.go.
/// </summary>
public sealed class RateCounter
{
private readonly long _limit;
private long _count;
private ulong _blocked;
private DateTime _end;
// Exposed for tests (mirrors direct field access in rate_counter_test.go).
public TimeSpan Interval;
private readonly object _lock = new();
public RateCounter(long limit)
{
_limit = limit;
Interval = TimeSpan.FromSeconds(1);
}
/// <summary>
/// Returns true if the event is within the rate limit for the current window.
/// Mirrors <c>rateCounter.allow</c>.
/// </summary>
public bool Allow()
{
var now = DateTime.UtcNow;
lock (_lock)
{
if (now > _end)
{
_count = 0;
_end = now + Interval;
}
else
{
_count++;
}
var allow = _count < _limit;
if (!allow)
_blocked++;
return allow;
}
}
/// <summary>
/// Returns and resets the count of blocked events since the last call.
/// Mirrors <c>rateCounter.countBlocked</c>.
/// </summary>
public ulong CountBlocked()
{
lock (_lock)
{
var blocked = _blocked;
_blocked = 0;
return blocked;
}
}
}

View File

@@ -0,0 +1,437 @@
// 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/util.go in the NATS server Go source.
using System.Net;
using System.Text.RegularExpressions;
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// General-purpose server utility methods.
/// Mirrors server/util.go.
/// </summary>
public static class ServerUtilities
{
// Semver validation regex — mirrors semVerRe in const.go.
private static readonly Regex SemVerRe = new(
@"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$",
RegexOptions.Compiled);
// -------------------------------------------------------------------------
// Version helpers
// -------------------------------------------------------------------------
/// <summary>
/// Parses a semver string into major/minor/patch components.
/// Returns an error if the string is not a valid semver.
/// Mirrors <c>versionComponents</c>.
/// </summary>
public static (int major, int minor, int patch, Exception? err) VersionComponents(string version)
{
var m = SemVerRe.Match(version);
if (!m.Success)
return (0, 0, 0, new InvalidOperationException("invalid semver"));
if (!int.TryParse(m.Groups[1].Value, out var major) ||
!int.TryParse(m.Groups[2].Value, out var minor) ||
!int.TryParse(m.Groups[3].Value, out var patch))
return (-1, -1, -1, new InvalidOperationException("invalid semver component"));
return (major, minor, patch, null);
}
/// <summary>
/// Returns (true, nil) if <paramref name="version"/> is at least major.minor.patch.
/// Mirrors <c>versionAtLeastCheckError</c>.
/// </summary>
public static (bool ok, Exception? err) VersionAtLeastCheckError(
string version, int emajor, int eminor, int epatch)
{
var (major, minor, patch, err) = VersionComponents(version);
if (err != null) return (false, err);
if (major > emajor) return (true, null);
if (major == emajor && minor > eminor) return (true, null);
if (major == emajor && minor == eminor && patch >= epatch) return (true, null);
return (false, null);
}
/// <summary>
/// Returns true if <paramref name="version"/> is at least major.minor.patch.
/// Mirrors <c>versionAtLeast</c>.
/// </summary>
public static bool VersionAtLeast(string version, int emajor, int eminor, int epatch)
{
var (ok, _) = VersionAtLeastCheckError(version, emajor, eminor, epatch);
return ok;
}
// -------------------------------------------------------------------------
// Integer parsing helpers (used for NATS protocol parsing)
// -------------------------------------------------------------------------
/// <summary>
/// Parses a decimal positive integer from ASCII bytes.
/// Returns -1 on error or if the input contains non-digit characters.
/// Mirrors <c>parseSize</c>.
/// </summary>
public static int ParseSize(ReadOnlySpan<byte> d)
{
const int MaxParseSizeLen = 9; // 999M
if (d.IsEmpty || d.Length > MaxParseSizeLen) return -1;
var n = 0;
foreach (var dec in d)
{
if (dec < '0' || dec > '9') return -1;
n = n * 10 + (dec - '0');
}
return n;
}
/// <summary>
/// Parses a decimal positive int64 from ASCII bytes.
/// Returns -1 on error.
/// Mirrors <c>parseInt64</c>.
/// </summary>
public static long ParseInt64(ReadOnlySpan<byte> d)
{
if (d.IsEmpty) return -1;
long n = 0;
foreach (var dec in d)
{
if (dec < '0' || dec > '9') return -1;
n = n * 10 + (dec - '0');
}
return n;
}
// -------------------------------------------------------------------------
// Duration / network helpers
// -------------------------------------------------------------------------
/// <summary>
/// Converts float64 seconds to a <see cref="TimeSpan"/>.
/// Mirrors <c>secondsToDuration</c>.
/// </summary>
public static TimeSpan SecondsToDuration(double seconds) =>
TimeSpan.FromSeconds(seconds);
/// <summary>
/// Splits "host:port" into components, using <paramref name="defaultPort"/>
/// when no port (or port 0 / -1) is present.
/// Mirrors <c>parseHostPort</c>.
/// </summary>
public static (string host, int port, Exception? err) ParseHostPort(string hostPort, int defaultPort)
{
if (string.IsNullOrEmpty(hostPort))
return ("", -1, new InvalidOperationException("no hostport specified"));
// Try splitting; if port is missing, append the default and retry.
string host, sPort;
try
{
var ep = ParseEndpoint(hostPort);
host = ep.host;
sPort = ep.port;
}
catch
{
try
{
var ep = ParseEndpoint($"{hostPort}:{defaultPort}");
host = ep.host;
sPort = ep.port;
}
catch (Exception ex)
{
return ("", -1, ex);
}
}
if (!int.TryParse(sPort.Trim(), out var port))
return ("", -1, new InvalidOperationException($"invalid port: {sPort}"));
if (port == 0 || port == -1)
port = defaultPort;
return (host.Trim(), port, null);
}
private static (string host, string port) ParseEndpoint(string hostPort)
{
// net.SplitHostPort equivalent — handles IPv6 [::1]:port
if (hostPort.StartsWith('['))
{
var closeIdx = hostPort.IndexOf(']');
if (closeIdx < 0 || closeIdx + 1 >= hostPort.Length || hostPort[closeIdx + 1] != ':')
throw new InvalidOperationException($"missing port in address {hostPort}");
return (hostPort[1..closeIdx], hostPort[(closeIdx + 2)..]);
}
var lastColon = hostPort.LastIndexOf(':');
if (lastColon < 0)
throw new InvalidOperationException($"missing port in address {hostPort}");
var host = hostPort[..lastColon];
var port = hostPort[(lastColon + 1)..];
// Reject bare IPv6 addresses (multiple colons without brackets).
if (host.Contains(':'))
throw new InvalidOperationException($"too many colons in address {hostPort}");
return (host, port);
}
/// <summary>
/// Returns true if two <see cref="Uri"/> instances represent the same URL.
/// Mirrors <c>urlsAreEqual</c>.
/// </summary>
public static bool UrlsAreEqual(Uri? u1, Uri? u2) =>
u1 == u2 || (u1 != null && u2 != null && u1.ToString() == u2.ToString());
// -------------------------------------------------------------------------
// Comma formatting
// -------------------------------------------------------------------------
/// <summary>
/// Formats an int64 with comma thousands separators.
/// Mirrors <c>comma</c> in util.go.
/// </summary>
public static string Comma(long v)
{
if (v == long.MinValue) return "-9,223,372,036,854,775,808";
var sign = "";
if (v < 0) { sign = "-"; v = -v; }
var parts = new string[7];
var j = parts.Length - 1;
while (v > 999)
{
var part = (v % 1000).ToString();
parts[j--] = part.Length switch { 2 => "0" + part, 1 => "00" + part, _ => part };
v /= 1000;
}
parts[j] = v.ToString();
return sign + string.Join(",", parts.Skip(j));
}
// -------------------------------------------------------------------------
// TCP helpers
// -------------------------------------------------------------------------
/// <summary>
/// Creates a TCP listener with keepalives disabled (NATS server default).
/// Mirrors <c>natsListen</c>.
/// </summary>
public static System.Net.Sockets.TcpListener NatsListen(string address, int port)
{
// .NET TcpListener does not set keepalive by default; the socket can be
// further configured after creation if needed.
var listener = new System.Net.Sockets.TcpListener(IPAddress.Parse(address), port);
return listener;
}
/// <summary>
/// Opens a TCP connection with the given timeout and keepalives disabled.
/// Mirrors <c>natsDialTimeout</c>.
/// </summary>
public static async Task<System.Net.Sockets.TcpClient> NatsDialTimeoutAsync(
string host, int port, TimeSpan timeout)
{
var client = new System.Net.Sockets.TcpClient();
// Disable keepalive to match Go 1.12 behavior.
client.Client.SetSocketOption(
System.Net.Sockets.SocketOptionLevel.Socket,
System.Net.Sockets.SocketOptionName.KeepAlive,
false);
using var cts = new CancellationTokenSource(timeout);
await client.ConnectAsync(host, port, cts.Token);
return client;
}
// -------------------------------------------------------------------------
// URL redaction
// -------------------------------------------------------------------------
/// <summary>
/// Returns a copy of <paramref name="unredacted"/> where any URL that
/// contains a password has its password replaced with "xxxxx".
/// Mirrors <c>redactURLList</c>.
/// </summary>
public static Uri[] RedactUrlList(Uri[] unredacted)
{
var r = new Uri[unredacted.Length];
var needCopy = false;
for (var i = 0; i < unredacted.Length; i++)
{
var u = unredacted[i];
if (u?.UserInfo?.Contains(':') == true)
{
needCopy = true;
var ui = u.UserInfo;
var colon = ui.IndexOf(':');
var username = ui[..colon];
var b = new UriBuilder(u) { Password = "xxxxx", UserName = username };
r[i] = b.Uri;
}
else
{
r[i] = u!;
}
}
return needCopy ? r : unredacted;
}
/// <summary>
/// Returns the URL string with the password component redacted ("xxxxx").
/// Returns the original string if no password is present or it cannot be parsed.
/// Mirrors <c>redactURLString</c>.
/// </summary>
public static string RedactUrlString(string raw)
{
if (!raw.Contains('@')) return raw;
if (!Uri.TryCreate(raw, UriKind.Absolute, out var u)) return raw;
if (!u.UserInfo.Contains(':')) return raw;
var colon = u.UserInfo.IndexOf(':');
var username = u.UserInfo[..colon];
var b = new UriBuilder(u) { Password = "xxxxx", UserName = username };
var result = b.Uri.ToString();
// UriBuilder adds a trailing slash for authority-only URLs; strip it if the input had none.
if (!raw.EndsWith('/') && result.EndsWith('/'))
result = result[..^1];
return result;
}
/// <summary>
/// Returns the Host part of each URL in the list.
/// Mirrors <c>getURLsAsString</c>.
/// </summary>
public static string[] GetUrlsAsString(Uri[] urls)
{
var result = new string[urls.Length];
for (var i = 0; i < urls.Length; i++)
result[i] = urls[i].Authority; // host:port
return result;
}
// -------------------------------------------------------------------------
// Copy helpers
// -------------------------------------------------------------------------
/// <summary>
/// Returns a copy of <paramref name="src"/>, or null if src is empty.
/// Mirrors <c>copyBytes</c>.
/// </summary>
public static byte[]? CopyBytes(byte[]? src)
{
if (src == null || src.Length == 0) return null;
var dst = new byte[src.Length];
src.CopyTo(dst, 0);
return dst;
}
/// <summary>
/// Returns a copy of <paramref name="src"/>, or null if src is null.
/// Mirrors <c>copyStrings</c>.
/// </summary>
public static string[]? CopyStrings(string[]? src)
{
if (src == null) return null;
var dst = new string[src.Length];
src.CopyTo(dst, 0);
return dst;
}
// -------------------------------------------------------------------------
// Parallel task queue
// -------------------------------------------------------------------------
/// <summary>
/// Creates a bounded channel onto which tasks can be posted for parallel
/// execution across a pool of dedicated threads. Close the returned channel
/// to signal workers to stop (after queued items complete).
/// Mirrors <c>parallelTaskQueue</c>.
/// </summary>
public static System.Threading.Channels.ChannelWriter<Action> CreateParallelTaskQueue(int maxParallelism = 0)
{
var mp = maxParallelism <= 0 ? Environment.ProcessorCount : Math.Max(Environment.ProcessorCount, maxParallelism);
var channel = System.Threading.Channels.Channel.CreateBounded<Action>(mp);
for (var i = 0; i < mp; i++)
{
Task.Run(async () =>
{
await foreach (var fn in channel.Reader.ReadAllAsync())
fn();
});
}
return channel.Writer;
}
}
// -------------------------------------------------------------------------
// RefCountedUrlSet (mirrors refCountedUrlSet map[string]int in util.go)
// -------------------------------------------------------------------------
/// <summary>
/// A reference-counted set of URL strings used for gossip URL management.
/// Mirrors <c>refCountedUrlSet</c> in server/util.go.
/// </summary>
public sealed class RefCountedUrlSet
{
private readonly Dictionary<string, int> _map = new();
/// <summary>
/// Adds <paramref name="urlStr"/>. Returns true if it was added for the first time.
/// Mirrors <c>refCountedUrlSet.addUrl</c>.
/// </summary>
public bool AddUrl(string urlStr)
{
_map.TryGetValue(urlStr, out var count);
_map[urlStr] = count + 1;
return count == 0;
}
/// <summary>
/// Decrements the reference count for <paramref name="urlStr"/>.
/// Returns true if this was the last reference (entry removed).
/// Mirrors <c>refCountedUrlSet.removeUrl</c>.
/// </summary>
public bool RemoveUrl(string urlStr)
{
if (!_map.TryGetValue(urlStr, out var count)) return false;
if (count == 1) { _map.Remove(urlStr); return true; }
_map[urlStr] = count - 1;
return false;
}
/// <summary>
/// Returns the unique URL strings currently in the set.
/// Mirrors <c>refCountedUrlSet.getAsStringSlice</c>.
/// </summary>
public string[] GetAsStringSlice() => [.. _map.Keys];
}

View File

@@ -0,0 +1,145 @@
// 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/signal.go and server/service.go in the NATS server Go source.
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// Maps <see cref="ServerCommand"/> to OS signal-like behavior.
/// Mirrors <c>CommandToSignal</c> and <c>ProcessSignal</c> from signal.go.
/// In .NET, signal sending is replaced by process-level signaling on Unix.
/// </summary>
public static class SignalHandler
{
private static string _processName = "nats-server";
/// <summary>
/// Sets the process name used for resolving PIDs.
/// Mirrors <c>SetProcessName</c> in signal.go.
/// </summary>
public static void SetProcessName(string name) => _processName = name;
/// <summary>
/// Sends a signal command to a running NATS server process.
/// On Unix, maps commands to kill signals.
/// On Windows, this is a no-op (service manager handles signals).
/// Mirrors <c>ProcessSignal</c> in signal.go.
/// </summary>
public static Exception? ProcessSignal(ServerCommand command, string pidExpr = "")
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return new PlatformNotSupportedException("Signal processing not supported on Windows; use service manager.");
try
{
List<int> pids;
if (string.IsNullOrEmpty(pidExpr))
{
pids = ResolvePids();
if (pids.Count == 0)
return new InvalidOperationException("no nats-server processes found");
}
else
{
if (int.TryParse(pidExpr, out var pid))
pids = [pid];
else
return new InvalidOperationException($"invalid pid: {pidExpr}");
}
var signal = CommandToUnixSignal(command);
foreach (var pid in pids)
Process.GetProcessById(pid).Kill(signal == UnixSignal.SigKill);
return null;
}
catch (Exception ex)
{
return ex;
}
}
/// <summary>
/// Resolves PIDs of running nats-server processes via pgrep.
/// Mirrors <c>resolvePids</c> in signal.go.
/// </summary>
public static List<int> ResolvePids()
{
var pids = new List<int>();
try
{
var psi = new ProcessStartInfo("pgrep", _processName)
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var proc = Process.Start(psi);
if (proc == null) return pids;
var output = proc.StandardOutput.ReadToEnd();
proc.WaitForExit();
var currentPid = Environment.ProcessId;
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
if (int.TryParse(line.Trim(), out var pid) && pid != currentPid)
pids.Add(pid);
}
}
catch
{
// pgrep not available or failed
}
return pids;
}
/// <summary>
/// Maps a server command to Unix signal.
/// Mirrors <c>CommandToSignal</c> in signal.go.
/// </summary>
public static UnixSignal CommandToUnixSignal(ServerCommand command) => command switch
{
ServerCommand.Stop => UnixSignal.SigKill,
ServerCommand.Quit => UnixSignal.SigInt,
ServerCommand.Reopen => UnixSignal.SigUsr1,
ServerCommand.Reload => UnixSignal.SigHup,
_ => throw new ArgumentOutOfRangeException(nameof(command), $"unknown command: {command}"),
};
/// <summary>
/// Runs the server (non-Windows). Mirrors <c>Run</c> in service.go.
/// </summary>
public static void Run(Action startServer) => startServer();
/// <summary>
/// Returns false on non-Windows. Mirrors <c>isWindowsService</c>.
/// </summary>
public static bool IsWindowsService() => false;
}
/// <summary>Unix signal codes for NATS command mapping.</summary>
public enum UnixSignal
{
SigInt = 2,
SigKill = 9,
SigUsr1 = 10,
SigHup = 1,
SigUsr2 = 12,
SigTerm = 15,
}

View File

@@ -0,0 +1,842 @@
// Copyright 2023-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/subject_transform.go in the NATS server Go source.
using System.Text.RegularExpressions;
namespace ZB.MOM.NatsNet.Server.Internal;
// -------------------------------------------------------------------------
// Subject token constants (mirrors const block in server/sublist.go)
// -------------------------------------------------------------------------
internal static class SubjectTokens
{
internal const char Pwc = '*'; // partial wildcard character
internal const string Pwcs = "*"; // partial wildcard string
internal const char Fwc = '>'; // full wildcard character
internal const string Fwcs = ">"; // full wildcard string
internal const string Tsep = "."; // token separator string
internal const char Btsep = '.'; // token separator character
internal const string Empty = ""; // _EMPTY_
}
// -------------------------------------------------------------------------
// Transform type constants (mirrors enum in subject_transform.go)
// -------------------------------------------------------------------------
internal static class TransformType
{
internal const short NoTransform = 0;
internal const short BadTransform = 1;
internal const short Partition = 2;
internal const short Wildcard = 3;
internal const short SplitFromLeft = 4;
internal const short SplitFromRight = 5;
internal const short SliceFromLeft = 6;
internal const short SliceFromRight = 7;
internal const short Split = 8;
internal const short Left = 9;
internal const short Right = 10;
internal const short Random = 11;
}
// -------------------------------------------------------------------------
// ISubjectTransformer interface (mirrors SubjectTransformer in Go)
// -------------------------------------------------------------------------
/// <summary>
/// Transforms NATS subjects according to a source-to-destination mapping.
/// Mirrors <c>SubjectTransformer</c> in server/subject_transform.go.
/// </summary>
public interface ISubjectTransformer
{
(string result, Exception? err) Match(string subject);
string TransformSubject(string subject);
string TransformTokenizedSubject(string[] tokens);
}
// -------------------------------------------------------------------------
// SubjectTransform class
// -------------------------------------------------------------------------
/// <summary>
/// Subject mapping and transform engine.
/// Mirrors <c>subjectTransform</c> in server/subject_transform.go.
/// </summary>
public sealed class SubjectTransform : ISubjectTransformer
{
private readonly string _src;
private readonly string _dest;
private readonly string[] _dtoks; // destination tokens
private readonly string[] _stoks; // source tokens
private readonly short[] _dtokmftypes;
private readonly int[][] _dtokmftokindexesargs;
private readonly int[] _dtokmfintargs;
private readonly string[] _dtokmfstringargs;
// Subject mapping function regexes (mirrors var block in Go).
private static readonly Regex CommaSep = new(@",\s*", RegexOptions.Compiled);
private static readonly Regex PartitionRe = new(@"\{\{\s*[pP]artition\s*\((.*)\)\s*\}\}", RegexOptions.Compiled);
private static readonly Regex WildcardRe = new(@"\{\{\s*[wW]ildcard\s*\((.*)\)\s*\}\}", RegexOptions.Compiled);
private static readonly Regex SplitFromLeftRe = new(@"\{\{\s*[sS]plit[fF]rom[lL]eft\s*\((.*)\)\s*\}\}", RegexOptions.Compiled);
private static readonly Regex SplitFromRightRe = new(@"\{\{\s*[sS]plit[fF]rom[rR]ight\s*\((.*)\)\s*\}\}", RegexOptions.Compiled);
private static readonly Regex SliceFromLeftRe = new(@"\{\{\s*[sS]lice[fF]rom[lL]eft\s*\((.*)\)\s*\}\}", RegexOptions.Compiled);
private static readonly Regex SliceFromRightRe = new(@"\{\{\s*[sS]lice[fF]rom[rR]ight\s*\((.*)\)\s*\}\}", RegexOptions.Compiled);
private static readonly Regex SplitRe = new(@"\{\{\s*[sS]plit\s*\((.*)\)\s*\}\}", RegexOptions.Compiled);
private static readonly Regex LeftRe = new(@"\{\{\s*[lL]eft\s*\((.*)\)\s*\}\}", RegexOptions.Compiled);
private static readonly Regex RightRe = new(@"\{\{\s*[rR]ight\s*\((.*)\)\s*\}\}", RegexOptions.Compiled);
private static readonly Regex RandomRe = new(@"\{\{\s*[rR]andom\s*\((.*)\)\s*\}\}", RegexOptions.Compiled);
private SubjectTransform(
string src, string dest,
string[] dtoks, string[] stoks,
short[] dtokmftypes, int[][] dtokmftokindexesargs,
int[] dtokmfintargs, string[] dtokmfstringargs)
{
_src = src;
_dest = dest;
_dtoks = dtoks;
_stoks = stoks;
_dtokmftypes = dtokmftypes;
_dtokmftokindexesargs = dtokmftokindexesargs;
_dtokmfintargs = dtokmfintargs;
_dtokmfstringargs = dtokmfstringargs;
}
/// <summary>
/// Creates a new transform with optional strict mode.
/// Returns (null, null) when dest is empty (no transform needed).
/// Mirrors <c>NewSubjectTransformWithStrict</c>.
/// </summary>
public static (SubjectTransform? transform, Exception? err) NewWithStrict(
string src, string dest, bool strict)
{
if (dest == SubjectTokens.Empty)
return (null, null);
if (src == SubjectTokens.Empty)
src = SubjectTokens.Fwcs;
var (sv, stokens, npwcs, hasFwc) = SubjectInfo(src);
var (dv, dtokens, dnpwcs, dHasFwc) = SubjectInfo(dest);
if (!sv || !dv || dnpwcs > 0 || hasFwc != dHasFwc)
return (null, ServerErrors.ErrBadSubject);
var dtokMfTypes = new List<short>();
var dtokMfIndexes = new List<int[]>();
var dtokMfIntArgs = new List<int>();
var dtokMfStringArgs = new List<string>();
if (npwcs > 0 || hasFwc)
{
// Build source-token index map for partial wildcards.
var sti = new Dictionary<int, int>();
for (var i = 0; i < stokens.Length; i++)
{
if (stokens[i].Length == 1 && stokens[i][0] == SubjectTokens.Pwc)
sti[sti.Count + 1] = i;
}
var nphs = 0;
foreach (var token in dtokens)
{
var (tt, tidxs, tint, tstr, terr) = IndexPlaceHolders(token);
if (terr != null) return (null, terr);
if (strict && tt != TransformType.NoTransform && tt != TransformType.Wildcard)
return (null, new MappingDestinationException(token, ServerErrors.ErrMappingDestinationNotSupportedForImport));
if (tt == TransformType.NoTransform)
{
dtokMfTypes.Add(TransformType.NoTransform);
dtokMfIndexes.Add([-1]);
dtokMfIntArgs.Add(-1);
dtokMfStringArgs.Add(SubjectTokens.Empty);
}
else if (tt == TransformType.Random)
{
dtokMfTypes.Add(TransformType.Random);
dtokMfIndexes.Add([]);
dtokMfIntArgs.Add(tint);
dtokMfStringArgs.Add(SubjectTokens.Empty);
}
else
{
nphs += tidxs.Length;
var stis = new List<int>();
foreach (var wildcardIndex in tidxs)
{
if (wildcardIndex > npwcs)
return (null, new MappingDestinationException(
$"{token}: [{wildcardIndex}]",
ServerErrors.ErrMappingDestinationIndexOutOfRange));
stis.Add(sti.GetValueOrDefault(wildcardIndex, 0));
}
dtokMfTypes.Add(tt);
dtokMfIndexes.Add([.. stis]);
dtokMfIntArgs.Add(tint);
dtokMfStringArgs.Add(tstr);
}
}
if (strict && nphs < npwcs)
return (null, new MappingDestinationException(dest, ServerErrors.ErrMappingDestinationNotUsingAllWildcards));
}
else
{
foreach (var token in dtokens)
{
var (tt, _, tint, _, terr) = IndexPlaceHolders(token);
if (terr != null) return (null, terr);
if (tt == TransformType.NoTransform)
{
dtokMfTypes.Add(TransformType.NoTransform);
dtokMfIndexes.Add([-1]);
dtokMfIntArgs.Add(-1);
dtokMfStringArgs.Add(SubjectTokens.Empty);
}
else if (tt == TransformType.Random || tt == TransformType.Partition)
{
dtokMfTypes.Add(tt);
dtokMfIndexes.Add([]);
dtokMfIntArgs.Add(tint);
dtokMfStringArgs.Add(SubjectTokens.Empty);
}
else
{
return (null, new MappingDestinationException(token, ServerErrors.ErrMappingDestinationIndexOutOfRange));
}
}
}
return (new SubjectTransform(
src, dest,
dtokens, stokens,
[.. dtokMfTypes], [.. dtokMfIndexes],
[.. dtokMfIntArgs], [.. dtokMfStringArgs]), null);
}
/// <summary>
/// Creates a non-strict transform. Mirrors <c>NewSubjectTransform</c>.
/// </summary>
public static (SubjectTransform? transform, Exception? err) New(string src, string dest) =>
NewWithStrict(src, dest, false);
/// <summary>
/// Creates a strict transform (only Wildcard function allowed).
/// Mirrors <c>NewSubjectTransformStrict</c>.
/// </summary>
public static (SubjectTransform? transform, Exception? err) NewStrict(string src, string dest) =>
NewWithStrict(src, dest, true);
/// <summary>
/// Attempts to match a published subject against the source pattern.
/// Returns the transformed subject or an error.
/// Mirrors <c>subjectTransform.Match</c>.
/// </summary>
public (string result, Exception? err) Match(string subject)
{
if ((_src == SubjectTokens.Fwcs || _src == SubjectTokens.Empty) &&
(_dest == SubjectTokens.Fwcs || _dest == SubjectTokens.Empty))
return (subject, null);
var tts = TokenizeSubject(subject);
if (!IsValidLiteralSubject(tts))
return (SubjectTokens.Empty, ServerErrors.ErrBadSubject);
if (_src == SubjectTokens.Empty || _src == SubjectTokens.Fwcs ||
IsSubsetMatch(tts, _src))
return (TransformTokenizedSubject(tts), null);
return (SubjectTokens.Empty, ServerErrors.ErrNoTransforms);
}
/// <summary>
/// Transforms a dot-separated subject string.
/// Mirrors <c>subjectTransform.TransformSubject</c>.
/// </summary>
public string TransformSubject(string subject) =>
TransformTokenizedSubject(TokenizeSubject(subject));
/// <summary>
/// Core token-by-token transform engine.
/// Mirrors <c>subjectTransform.TransformTokenizedSubject</c>.
/// </summary>
public string TransformTokenizedSubject(string[] tokens)
{
if (_dtokmftypes.Length == 0)
return _dest;
var b = new System.Text.StringBuilder();
var li = _dtokmftypes.Length - 1;
for (var i = 0; i < _dtokmftypes.Length; i++)
{
var mfType = _dtokmftypes[i];
if (mfType == TransformType.NoTransform)
{
if (_dtoks[i].Length == 1 && _dtoks[i][0] == SubjectTokens.Fwc)
break;
b.Append(_dtoks[i]);
}
else
{
switch (mfType)
{
case TransformType.Partition:
{
byte[] keyBytes;
if (_dtokmftokindexesargs[i].Length > 0)
{
var sb = new System.Text.StringBuilder();
foreach (var srcTok in _dtokmftokindexesargs[i])
sb.Append(tokens[srcTok]);
keyBytes = System.Text.Encoding.UTF8.GetBytes(sb.ToString());
}
else
{
keyBytes = System.Text.Encoding.UTF8.GetBytes(string.Join(".", tokens));
}
b.Append(GetHashPartition(keyBytes, _dtokmfintargs[i]));
break;
}
case TransformType.Wildcard:
if (_dtokmftokindexesargs.Length > i &&
_dtokmftokindexesargs[i].Length > 0 &&
tokens.Length > _dtokmftokindexesargs[i][0])
{
b.Append(tokens[_dtokmftokindexesargs[i][0]]);
}
break;
case TransformType.SplitFromLeft:
{
var src = tokens[_dtokmftokindexesargs[i][0]];
var pos = _dtokmfintargs[i];
if (pos > 0 && pos < src.Length)
{
b.Append(src[..pos]);
b.Append(SubjectTokens.Tsep);
b.Append(src[pos..]);
}
else
{
b.Append(src);
}
break;
}
case TransformType.SplitFromRight:
{
var src = tokens[_dtokmftokindexesargs[i][0]];
var pos = _dtokmfintargs[i];
if (pos > 0 && pos < src.Length)
{
b.Append(src[..(src.Length - pos)]);
b.Append(SubjectTokens.Tsep);
b.Append(src[(src.Length - pos)..]);
}
else
{
b.Append(src);
}
break;
}
case TransformType.SliceFromLeft:
{
var src = tokens[_dtokmftokindexesargs[i][0]];
var sz = _dtokmfintargs[i];
if (sz > 0 && sz < src.Length)
{
var j = 0;
while (j + sz <= src.Length)
{
if (j != 0) b.Append(SubjectTokens.Tsep);
b.Append(src[j..(j + sz)]);
if (j + sz != src.Length && j + sz + sz > src.Length)
{
b.Append(SubjectTokens.Tsep);
b.Append(src[(j + sz)..]);
break;
}
j += sz;
}
}
else
{
b.Append(src);
}
break;
}
case TransformType.SliceFromRight:
{
var src = tokens[_dtokmftokindexesargs[i][0]];
var sz = _dtokmfintargs[i];
if (sz > 0 && sz < src.Length)
{
var rem = src.Length % sz;
if (rem > 0)
{
b.Append(src[..rem]);
b.Append(SubjectTokens.Tsep);
}
var j = rem;
while (j + sz <= src.Length)
{
b.Append(src[j..(j + sz)]);
if (j + sz < src.Length) b.Append(SubjectTokens.Tsep);
j += sz;
}
}
else
{
b.Append(src);
}
break;
}
case TransformType.Split:
{
var src = tokens[_dtokmftokindexesargs[i][0]];
var parts = src.Split(_dtokmfstringargs[i]);
for (var j = 0; j < parts.Length; j++)
{
if (parts[j] != SubjectTokens.Empty)
b.Append(parts[j]);
if (j < parts.Length - 1 &&
parts[j + 1] != SubjectTokens.Empty &&
!(j == 0 && parts[j] == SubjectTokens.Empty))
b.Append(SubjectTokens.Tsep);
}
break;
}
case TransformType.Left:
{
var src = tokens[_dtokmftokindexesargs[i][0]];
var sz = _dtokmfintargs[i];
b.Append(sz > 0 && sz < src.Length ? src[..sz] : src);
break;
}
case TransformType.Right:
{
var src = tokens[_dtokmftokindexesargs[i][0]];
var sz = _dtokmfintargs[i];
b.Append(sz > 0 && sz < src.Length ? src[(src.Length - sz)..] : src);
break;
}
case TransformType.Random:
b.Append(GetRandomPartition(_dtokmfintargs[i]));
break;
}
}
if (i < li)
b.Append(SubjectTokens.Btsep);
}
// Append remaining source tokens when destination ends with ">".
if (_dtoks.Length > 0 && _dtoks[^1] == SubjectTokens.Fwcs)
{
var stokLen = _stoks.Length;
for (var i = stokLen - 1; i < tokens.Length; i++)
{
b.Append(tokens[i]);
if (i < tokens.Length - 1)
b.Append(SubjectTokens.Btsep);
}
}
return b.ToString();
}
/// <summary>
/// Reverses this transform (src ↔ dest).
/// Mirrors <c>subjectTransform.reverse</c>.
/// </summary>
internal SubjectTransform? Reverse()
{
if (_dtokmftokindexesargs.Length == 0)
{
var (rtr, _) = NewStrict(_dest, _src);
return rtr;
}
var (nsrc, phs) = TransformUntokenize(_dest);
var nda = new List<string>();
foreach (var token in _stoks)
{
if (token == SubjectTokens.Pwcs)
{
if (phs.Length == 0) return null;
nda.Add(phs[0]);
phs = phs[1..];
}
else
{
nda.Add(token);
}
}
var ndest = string.Join(SubjectTokens.Tsep, nda);
var (rtrFinal, _) = NewStrict(nsrc, ndest);
return rtrFinal;
}
// -------------------------------------------------------------------------
// Static helpers exposed internally
// -------------------------------------------------------------------------
/// <summary>
/// Returns the args extracted from a mapping-function token using the given regex.
/// Mirrors <c>getMappingFunctionArgs</c>.
/// </summary>
internal static string[]? GetMappingFunctionArgs(Regex functionRegex, string token)
{
var m = functionRegex.Match(token);
if (m.Success && m.Groups.Count > 1)
return CommaSep.Split(m.Groups[1].Value);
return null;
}
/// <summary>
/// Helper for transform functions that take (wildcardIndex, int) args.
/// Mirrors <c>transformIndexIntArgsHelper</c>.
/// </summary>
internal static (short tt, int[] indexes, int intArg, string strArg, Exception? err)
TransformIndexIntArgsHelper(string token, string[] args, short transformType)
{
if (args.Length < 2)
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationNotEnoughArgs));
if (args.Length > 2)
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationTooManyArgs));
if (!int.TryParse(args[0].Trim(), out var idx))
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationInvalidArg));
if (!int.TryParse(args[1].Trim(), out var intVal))
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationInvalidArg));
return (transformType, [idx], intVal, SubjectTokens.Empty, null);
}
/// <summary>
/// Parses a destination token and returns its transform type and arguments.
/// Mirrors <c>indexPlaceHolders</c>.
/// </summary>
internal static (short tt, int[] indexes, int intArg, string strArg, Exception? err)
IndexPlaceHolders(string token)
{
var length = token.Length;
if (length > 1)
{
if (token[0] == '$')
{
if (!int.TryParse(token[1..], out var tp))
return (TransformType.NoTransform, [-1], -1, SubjectTokens.Empty, null);
return (TransformType.Wildcard, [tp], -1, SubjectTokens.Empty, null);
}
if (length > 4 && token[0] == '{' && token[1] == '{' &&
token[length - 2] == '}' && token[length - 1] == '}')
{
// {{wildcard(n)}}
var args = GetMappingFunctionArgs(WildcardRe, token);
if (args != null)
{
if (args.Length == 1 && args[0] == SubjectTokens.Empty)
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationNotEnoughArgs));
if (args.Length == 1)
{
if (!int.TryParse(args[0].Trim(), out var ti))
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationInvalidArg));
return (TransformType.Wildcard, [ti], -1, SubjectTokens.Empty, null);
}
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationTooManyArgs));
}
// {{partition(n[,t1,t2,...])}}
args = GetMappingFunctionArgs(PartitionRe, token);
if (args != null)
{
if (args.Length < 1)
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationNotEnoughArgs));
if (!int.TryParse(args[0].Trim(), out var partN) || (long)partN > int.MaxValue)
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationInvalidArg));
if (args.Length == 1)
return (TransformType.Partition, [], partN, SubjectTokens.Empty, null);
var tidxs = new List<int>();
foreach (var t in args[1..])
{
if (!int.TryParse(t.Trim(), out var ti2))
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationInvalidArg));
tidxs.Add(ti2);
}
return (TransformType.Partition, [.. tidxs], partN, SubjectTokens.Empty, null);
}
// {{SplitFromLeft(t, n)}}
args = GetMappingFunctionArgs(SplitFromLeftRe, token);
if (args != null) return TransformIndexIntArgsHelper(token, args, TransformType.SplitFromLeft);
// {{SplitFromRight(t, n)}}
args = GetMappingFunctionArgs(SplitFromRightRe, token);
if (args != null) return TransformIndexIntArgsHelper(token, args, TransformType.SplitFromRight);
// {{SliceFromLeft(t, n)}}
args = GetMappingFunctionArgs(SliceFromLeftRe, token);
if (args != null) return TransformIndexIntArgsHelper(token, args, TransformType.SliceFromLeft);
// {{SliceFromRight(t, n)}}
args = GetMappingFunctionArgs(SliceFromRightRe, token);
if (args != null) return TransformIndexIntArgsHelper(token, args, TransformType.SliceFromRight);
// {{right(t, n)}}
args = GetMappingFunctionArgs(RightRe, token);
if (args != null) return TransformIndexIntArgsHelper(token, args, TransformType.Right);
// {{left(t, n)}}
args = GetMappingFunctionArgs(LeftRe, token);
if (args != null) return TransformIndexIntArgsHelper(token, args, TransformType.Left);
// {{split(t, delim)}}
args = GetMappingFunctionArgs(SplitRe, token);
if (args != null)
{
if (args.Length < 2)
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationNotEnoughArgs));
if (args.Length > 2)
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationTooManyArgs));
if (!int.TryParse(args[0].Trim(), out var splitIdx))
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationInvalidArg));
if (args[1].Contains(' ') || args[1].Contains(SubjectTokens.Tsep))
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationInvalidArg));
return (TransformType.Split, [splitIdx], -1, args[1], null);
}
// {{random(n)}}
args = GetMappingFunctionArgs(RandomRe, token);
if (args != null)
{
if (args.Length != 1)
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationNotEnoughArgs));
if (!int.TryParse(args[0].Trim(), out var randN) || (long)randN > int.MaxValue)
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrMappingDestinationInvalidArg));
return (TransformType.Random, [], randN, SubjectTokens.Empty, null);
}
return (TransformType.BadTransform, [], -1, SubjectTokens.Empty,
new MappingDestinationException(token, ServerErrors.ErrUnknownMappingDestinationFunction));
}
}
return (TransformType.NoTransform, [-1], -1, SubjectTokens.Empty, null);
}
/// <summary>
/// Tokenises a subject with wildcards into a formal transform destination.
/// e.g. "foo.*.*" → "foo.$1.$2".
/// Mirrors <c>transformTokenize</c>.
/// </summary>
public static string TransformTokenize(string subject)
{
var i = 1;
var parts = new List<string>();
foreach (var token in subject.Split(SubjectTokens.Btsep))
{
if (token == SubjectTokens.Pwcs)
{
parts.Add($"${i++}");
}
else
{
parts.Add(token);
}
}
return string.Join(SubjectTokens.Tsep, parts);
}
/// <summary>
/// Converts a transform destination back to a wildcard subject + placeholder list.
/// Mirrors <c>transformUntokenize</c>.
/// </summary>
public static (string subject, string[] placeholders) TransformUntokenize(string subject)
{
var phs = new List<string>();
var nda = new List<string>();
foreach (var token in subject.Split(SubjectTokens.Btsep))
{
var args = GetMappingFunctionArgs(WildcardRe, token);
var isWildcardPlaceholder =
(token.Length > 1 && token[0] == '$' && token[1] >= '1' && token[1] <= '9') ||
(args?.Length == 1 && args[0] != SubjectTokens.Empty);
if (isWildcardPlaceholder)
{
phs.Add(token);
nda.Add(SubjectTokens.Pwcs);
}
else
{
nda.Add(token);
}
}
return (string.Join(SubjectTokens.Tsep, nda), [.. phs]);
}
/// <summary>
/// Tokenises a subject into an array of dot-separated tokens.
/// Mirrors <c>tokenizeSubject</c>.
/// </summary>
public static string[] TokenizeSubject(string subject) =>
subject.Split(SubjectTokens.Btsep);
/// <summary>
/// Returns (valid, tokens, numPwcs, hasFwc) for a subject string.
/// Mirrors <c>subjectInfo</c>.
/// </summary>
public static (bool valid, string[] tokens, int npwcs, bool hasFwc) SubjectInfo(string subject)
{
if (subject == string.Empty)
return (false, [], 0, false);
var npwcs = 0;
var sfwc = false;
var tokens = subject.Split(SubjectTokens.Tsep);
foreach (var t in tokens)
{
if (t.Length == 0 || sfwc)
return (false, [], 0, false);
if (t.Length > 1) continue;
switch (t[0])
{
case SubjectTokens.Fwc:
sfwc = true;
break;
case SubjectTokens.Pwc:
npwcs++;
break;
}
}
return (true, tokens, npwcs, sfwc);
}
// -------------------------------------------------------------------------
// Internal helpers used by Match
// -------------------------------------------------------------------------
/// <summary>
/// Returns true if all tokens are literal (no wildcards).
/// Mirrors <c>isValidLiteralSubject</c> in server/sublist.go.
/// </summary>
internal static bool IsValidLiteralSubject(string[] tokens)
{
foreach (var t in tokens)
{
if (t.Length == 0) return false;
if (t.Length == 1 && (t[0] == SubjectTokens.Pwc || t[0] == SubjectTokens.Fwc))
return false;
}
return true;
}
/// <summary>
/// Returns true if <paramref name="tokens"/> match the pattern <paramref name="test"/>.
/// Mirrors <c>isSubsetMatch</c> in server/sublist.go.
/// </summary>
internal static bool IsSubsetMatch(string[] tokens, string test)
{
var testToks = TokenizeSubjectIntoSlice(test);
return IsSubsetMatchTokenized(tokens, testToks);
}
private static string[] TokenizeSubjectIntoSlice(string subject)
{
var result = new List<string>();
var start = 0;
for (var i = 0; i < subject.Length; i++)
{
if (subject[i] == SubjectTokens.Btsep)
{
result.Add(subject[start..i]);
start = i + 1;
}
}
result.Add(subject[start..]);
return [.. result];
}
private static bool IsSubsetMatchTokenized(string[] tokens, string[] test)
{
for (var i = 0; i < test.Length; i++)
{
if (i >= tokens.Length) return false;
var t2 = test[i];
if (t2.Length == 0) return false;
if (t2.Length == 1 && t2[0] == SubjectTokens.Fwc) return true;
var t1 = tokens[i];
if (t1.Length == 0) return false;
if (t1.Length == 1 && t1[0] == SubjectTokens.Fwc) return false;
if (t1.Length == 1 && t1[0] == SubjectTokens.Pwc)
{
if (!(t2.Length == 1 && t2[0] == SubjectTokens.Pwc)) return false;
if (i >= test.Length) return true;
continue;
}
if (!(t2.Length == 1 && t2[0] == SubjectTokens.Pwc) &&
string.Compare(t1, t2, StringComparison.Ordinal) != 0)
return false;
}
return tokens.Length == test.Length;
}
private string GetRandomPartition(int ceiling)
{
if (ceiling == 0) return "0";
return (Random.Shared.Next() % ceiling).ToString();
}
private static string GetHashPartition(byte[] key, int numBuckets)
{
if (numBuckets == 0) return "0";
// FNV-1a 32-bit hash — mirrors fnv.New32a() in Go.
const uint FnvPrime = 16777619;
const uint FnvOffset = 2166136261;
var hash = FnvOffset;
foreach (var b in key) { hash ^= b; hash *= FnvPrime; }
return ((int)(hash % (uint)numBuckets)).ToString();
}
}

View File

@@ -0,0 +1,77 @@
// 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/client.go (subscription struct) in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// Represents a client subscription in the NATS server.
/// Mirrors the Go <c>subscription</c> struct from client.go.
/// This is a minimal stub; full client integration will be added in later sessions.
/// </summary>
public sealed class Subscription
{
/// <summary>The subject this subscription is listening on.</summary>
public byte[] Subject { get; set; } = [];
/// <summary>The queue group name, or null/empty for non-queue subscriptions.</summary>
public byte[]? Queue { get; set; }
/// <summary>The subscription identifier.</summary>
public byte[]? Sid { get; set; }
/// <summary>Queue weight for remote queue subscriptions.</summary>
public int Qw;
/// <summary>Closed flag (0 = open, 1 = closed).</summary>
private int _closed;
/// <summary>The client that owns this subscription. Null in test/stub scenarios.</summary>
public NatsClient? Client { get; set; }
/// <summary>Marks this subscription as closed.</summary>
public void Close() => Interlocked.Exchange(ref _closed, 1);
/// <summary>Returns true if this subscription has been closed.</summary>
public bool IsClosed() => Interlocked.CompareExchange(ref _closed, 0, 0) == 1;
}
/// <summary>
/// Represents the kind of client connection.
/// Mirrors Go's <c>clientKind</c> enum.
/// This is a minimal stub; full implementation in later sessions.
/// </summary>
public enum ClientKind
{
Client = 0,
Router = 1,
Gateway = 2,
System = 3,
Leaf = 4,
JetStream = 5,
Account = 6,
}
/// <summary>
/// Minimal client stub for subscription routing.
/// Full implementation will be added in later sessions.
/// </summary>
public class NatsClient
{
/// <summary>The kind of client connection.</summary>
public ClientKind Kind { get; set; }
/// <summary>Whether this is a hub leaf node. Stub for now.</summary>
public virtual bool IsHubLeafNode() => false;
}

View File

@@ -0,0 +1,95 @@
using System.Runtime.InteropServices;
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// Returns total physical memory available to the system in bytes.
/// Mirrors the Go <c>sysmem</c> package with platform-specific implementations.
/// Returns 0 if the value cannot be determined on the current platform.
/// </summary>
public static class SystemMemory
{
/// <summary>Returns total physical memory in bytes, or 0 on failure.</summary>
public static long Memory()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return MemoryWindows();
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
return MemoryDarwin();
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
return MemoryLinux();
return 0;
}
// --- macOS ---
internal static long MemoryDarwin() => SysctlInt64("hw.memsize");
/// <summary>
/// Reads an int64 sysctl value by name on BSD-derived systems (macOS, FreeBSD, etc.).
/// </summary>
internal static unsafe long SysctlInt64(string name)
{
var size = (nuint)sizeof(long);
long value = 0;
var ret = sysctlbyname(name, &value, &size, IntPtr.Zero, 0);
return ret == 0 ? value : 0;
}
[DllImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)]
private static extern unsafe int sysctlbyname(
string name,
void* oldp,
nuint* oldlenp,
IntPtr newp,
nuint newlen);
// --- Linux ---
internal static long MemoryLinux()
{
try
{
// Parse MemTotal from /proc/meminfo (value is in kB).
foreach (var line in File.ReadLines("/proc/meminfo"))
{
if (!line.StartsWith("MemTotal:", StringComparison.Ordinal))
continue;
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && long.TryParse(parts[1], out var kb))
return kb * 1024L;
}
}
catch
{
// Fall through to return 0.
}
return 0;
}
// --- Windows ---
[StructLayout(LayoutKind.Sequential)]
private struct MemoryStatusEx
{
public uint dwLength;
public uint dwMemoryLoad;
public ulong ullTotalPhys;
public ulong ullAvailPhys;
public ulong ullTotalPageFile;
public ulong ullAvailPageFile;
public ulong ullTotalVirtual;
public ulong ullAvailVirtual;
public ulong ullAvailExtendedVirtual;
}
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GlobalMemoryStatusEx(ref MemoryStatusEx lpBuffer);
internal static long MemoryWindows()
{
var msx = new MemoryStatusEx { dwLength = (uint)Marshal.SizeOf<MemoryStatusEx>() };
return GlobalMemoryStatusEx(ref msx) ? (long)msx.ullTotalPhys : 0;
}
}

View File

@@ -0,0 +1,389 @@
// Copyright 2012-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/client.go (header utility functions) in the NATS server Go source.
using System.Text;
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// Wire-level NATS message header constants.
/// </summary>
public static class NatsHeaderConstants
{
/// <summary>NATS header status line: <c>"NATS/1.0\r\n"</c>. Mirrors Go <c>hdrLine</c>.</summary>
public const string HdrLine = "NATS/1.0\r\n";
/// <summary>Empty header block with blank line terminator. Mirrors Go <c>emptyHdrLine</c>.</summary>
public const string EmptyHdrLine = "NATS/1.0\r\n\r\n";
// JetStream expected-sequence headers (defined in server/stream.go, used by header utilities).
public const string JsExpectedStream = "Nats-Expected-Stream";
public const string JsExpectedLastSeq = "Nats-Expected-Last-Sequence";
public const string JsExpectedLastSubjSeq = "Nats-Expected-Last-Subject-Sequence";
public const string JsExpectedLastSubjSeqSubj = "Nats-Expected-Last-Subject-Sequence-Subject";
public const string JsExpectedLastMsgId = "Nats-Expected-Last-Msg-Id";
// Other commonly used headers.
public const string JsMsgId = "Nats-Msg-Id";
public const string JsMsgRollup = "Nats-Rollup";
}
/// <summary>
/// Low-level NATS message header manipulation utilities.
/// Mirrors the package-level functions in server/client.go:
/// <c>genHeader</c>, <c>removeHeaderIfPresent</c>, <c>removeHeaderIfPrefixPresent</c>,
/// <c>getHeader</c>, <c>sliceHeader</c>, <c>getHeaderKeyIndex</c>, <c>setHeader</c>.
/// </summary>
public static class NatsMessageHeaders
{
private static readonly byte[] CrLfBytes = "\r\n"u8.ToArray();
// -------------------------------------------------------------------------
// genHeader (feature 506)
// -------------------------------------------------------------------------
/// <summary>
/// Generates a header buffer by appending <c>key: value\r\n</c> to an existing header,
/// or starting a fresh <c>NATS/1.0\r\n</c> block if <paramref name="hdr"/> is empty/null.
/// Mirrors Go <c>genHeader</c>.
/// </summary>
/// <param name="hdr">Existing header bytes, or <c>null</c> to start fresh.</param>
/// <param name="key">Header key.</param>
/// <param name="value">Header value.</param>
public static byte[] GenHeader(byte[]? hdr, string key, string value)
{
var sb = new StringBuilder();
// Strip trailing CRLF from existing header to reopen for appending,
// or start fresh with the header status line.
const int LenCrLf = 2;
if (hdr is { Length: > LenCrLf })
{
// Write all but the trailing "\r\n"
sb.Append(Encoding.ASCII.GetString(hdr, 0, hdr.Length - LenCrLf));
}
else
{
sb.Append(NatsHeaderConstants.HdrLine);
}
// Append "key: value\r\n\r\n" (HTTP header format).
sb.Append(key);
sb.Append(": ");
sb.Append(value);
sb.Append("\r\n\r\n");
return Encoding.ASCII.GetBytes(sb.ToString());
}
// -------------------------------------------------------------------------
// removeHeaderIfPresent (feature 504)
// -------------------------------------------------------------------------
/// <summary>
/// Removes the first occurrence of <paramref name="key"/> header from <paramref name="hdr"/>.
/// Returns <c>null</c> if the result would be an empty header block.
/// Mirrors Go <c>removeHeaderIfPresent</c>.
/// </summary>
public static byte[]? RemoveHeaderIfPresent(byte[] hdr, string key)
{
int start = GetHeaderKeyIndex(key, hdr);
// Key must exist and be preceded by '\n' (not at position 0).
if (start < 1 || hdr[start - 1] != '\n')
return hdr;
int index = start + key.Length;
if (index >= hdr.Length || hdr[index] != ':')
return hdr;
// Find CRLF following this header line.
int crlfIdx = IndexOfCrLf(hdr, start);
if (crlfIdx < 0)
return hdr;
// Remove from 'start' through end of CRLF.
int removeEnd = start + crlfIdx + 2; // +2 for "\r\n"
var result = new byte[hdr.Length - (removeEnd - start)];
Buffer.BlockCopy(hdr, 0, result, 0, start);
Buffer.BlockCopy(hdr, removeEnd, result, start, hdr.Length - removeEnd);
// If nothing meaningful remains, return null.
if (result.Length <= NatsHeaderConstants.EmptyHdrLine.Length)
return null;
return result;
}
// -------------------------------------------------------------------------
// removeHeaderIfPrefixPresent (feature 505)
// -------------------------------------------------------------------------
/// <summary>
/// Removes all headers whose names start with <paramref name="prefix"/>.
/// Returns <c>null</c> if the result would be an empty header block.
/// Mirrors Go <c>removeHeaderIfPrefixPresent</c>.
/// </summary>
public static byte[]? RemoveHeaderIfPrefixPresent(byte[] hdr, string prefix)
{
var prefixBytes = Encoding.ASCII.GetBytes(prefix);
var working = hdr.ToList(); // work on a list for easy splicing
int index = 0;
while (index < working.Count)
{
// Look for prefix starting at current index.
int found = IndexOf(working, prefixBytes, index);
if (found < 0)
break;
// Must be preceded by '\n'.
if (found < 1 || working[found - 1] != '\n')
break;
// Find CRLF after this prefix's key:value line.
int crlfIdx = IndexOfCrLf(working, found + prefix.Length);
if (crlfIdx < 0)
break;
int removeEnd = found + prefix.Length + crlfIdx + 2;
working.RemoveRange(found, removeEnd - found);
// Don't advance index — there may be more headers at same position.
if (working.Count <= NatsHeaderConstants.EmptyHdrLine.Length)
return null;
}
return working.ToArray();
}
// -------------------------------------------------------------------------
// getHeaderKeyIndex (feature 510)
// -------------------------------------------------------------------------
/// <summary>
/// Returns the byte offset of <paramref name="key"/> in <paramref name="hdr"/>,
/// or <c>-1</c> if not found.
/// The key must be preceded by <c>\r\n</c> and followed by <c>:</c>.
/// Mirrors Go <c>getHeaderKeyIndex</c>.
/// </summary>
public static int GetHeaderKeyIndex(string key, byte[] hdr)
{
if (hdr.Length == 0) return -1;
var bkey = Encoding.ASCII.GetBytes(key);
int keyLen = bkey.Length;
int hdrLen = hdr.Length;
int offset = 0;
while (true)
{
int index = IndexOf(hdr, bkey, offset);
// Need index >= 2 (room for preceding \r\n) and enough space for trailing colon.
if (index < 2) return -1;
// Preceded by \r\n ?
if (hdr[index - 1] != '\n' || hdr[index - 2] != '\r')
{
offset = index + keyLen;
continue;
}
// Immediately followed by ':' ?
if (index + keyLen >= hdrLen)
return -1;
if (hdr[index + keyLen] != ':')
{
offset = index + keyLen;
continue;
}
return index;
}
}
// -------------------------------------------------------------------------
// sliceHeader (feature 509)
// -------------------------------------------------------------------------
/// <summary>
/// Returns a slice of <paramref name="hdr"/> containing the value of <paramref name="key"/>,
/// or <c>null</c> if not found.
/// The returned slice shares memory with <paramref name="hdr"/>.
/// Mirrors Go <c>sliceHeader</c>.
/// </summary>
public static ReadOnlyMemory<byte>? SliceHeader(string key, byte[] hdr)
{
if (hdr.Length == 0) return null;
int index = GetHeaderKeyIndex(key, hdr);
if (index == -1) return null;
// Skip over key + ':' separator.
index += key.Length + 1;
int hdrLen = hdr.Length;
// Skip leading whitespace.
while (index < hdrLen && hdr[index] == ' ')
index++;
int start = index;
// Collect until CRLF.
while (index < hdrLen)
{
if (hdr[index] == '\r' && index + 1 < hdrLen && hdr[index + 1] == '\n')
break;
index++;
}
// Return a slice with capped length == value length (no extra capacity).
return new ReadOnlyMemory<byte>(hdr, start, index - start);
}
// -------------------------------------------------------------------------
// getHeader (feature 508)
// -------------------------------------------------------------------------
/// <summary>
/// Returns a copy of the value for the header named <paramref name="key"/>,
/// or <c>null</c> if not found.
/// Mirrors Go <c>getHeader</c>.
/// </summary>
public static byte[]? GetHeader(string key, byte[] hdr)
{
var slice = SliceHeader(key, hdr);
if (slice is null) return null;
// Return a fresh copy.
return slice.Value.ToArray();
}
// -------------------------------------------------------------------------
// setHeader (feature 511)
// -------------------------------------------------------------------------
/// <summary>
/// Replaces the value of the first existing <paramref name="key"/> header in
/// <paramref name="hdr"/>, or appends a new header if the key is absent.
/// Returns a new buffer when the new value is larger; modifies in-place otherwise.
/// Mirrors Go <c>setHeader</c>.
/// </summary>
public static byte[] SetHeader(string key, string val, byte[] hdr)
{
int start = GetHeaderKeyIndex(key, hdr);
if (start >= 0)
{
int valStart = start + key.Length + 1; // skip past ':'
int hdrLen = hdr.Length;
// Preserve a single leading space if present.
if (valStart < hdrLen && hdr[valStart] == ' ')
valStart++;
// Find the CR before the CRLF.
int crIdx = IndexOf(hdr, [(byte)'\r'], valStart);
if (crIdx < 0) return hdr; // malformed
int valEnd = crIdx;
int oldValLen = valEnd - valStart;
var valBytes = Encoding.ASCII.GetBytes(val);
int extra = valBytes.Length - oldValLen;
if (extra > 0)
{
// New value is larger — must allocate a new buffer.
var newHdr = new byte[hdrLen + extra];
Buffer.BlockCopy(hdr, 0, newHdr, 0, valStart);
Buffer.BlockCopy(valBytes, 0, newHdr, valStart, valBytes.Length);
Buffer.BlockCopy(hdr, valEnd, newHdr, valStart + valBytes.Length, hdrLen - valEnd);
return newHdr;
}
// Write in place (new value fits).
int n = valBytes.Length;
Buffer.BlockCopy(valBytes, 0, hdr, valStart, n);
// Shift remainder left.
Buffer.BlockCopy(hdr, valEnd, hdr, valStart + n, hdrLen - valEnd);
return hdr[..(valStart + n + hdrLen - valEnd)];
}
// Key not present — append.
bool hasTrailingCrLf = hdr.Length >= 2
&& hdr[^2] == '\r'
&& hdr[^1] == '\n';
byte[] suffix;
if (hasTrailingCrLf)
{
// Strip trailing CRLF, append "key: val\r\n\r\n".
suffix = Encoding.ASCII.GetBytes($"{key}: {val}\r\n");
var result = new byte[hdr.Length - 2 + suffix.Length + 2];
Buffer.BlockCopy(hdr, 0, result, 0, hdr.Length - 2);
Buffer.BlockCopy(suffix, 0, result, hdr.Length - 2, suffix.Length);
result[^2] = (byte)'\r';
result[^1] = (byte)'\n';
return result;
}
suffix = Encoding.ASCII.GetBytes($"{key}: {val}\r\n");
var newBuf = new byte[hdr.Length + suffix.Length];
Buffer.BlockCopy(hdr, 0, newBuf, 0, hdr.Length);
Buffer.BlockCopy(suffix, 0, newBuf, hdr.Length, suffix.Length);
return newBuf;
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
private static int IndexOf(byte[] haystack, byte[] needle, int offset)
{
var span = haystack.AsSpan(offset);
int idx = span.IndexOf(needle);
return idx < 0 ? -1 : offset + idx;
}
private static int IndexOf(List<byte> haystack, byte[] needle, int offset)
{
for (int i = offset; i <= haystack.Count - needle.Length; i++)
{
bool match = true;
for (int j = 0; j < needle.Length; j++)
{
if (haystack[i + j] != needle[j]) { match = false; break; }
}
if (match) return i;
}
return -1;
}
/// <summary>Returns the offset of the first \r\n in <paramref name="hdr"/> at or after <paramref name="offset"/>.</summary>
private static int IndexOfCrLf(byte[] hdr, int offset)
{
var span = hdr.AsSpan(offset);
int idx = span.IndexOf(CrLfBytes);
return idx; // relative to offset
}
private static int IndexOfCrLf(List<byte> hdr, int offset)
{
for (int i = offset; i < hdr.Count - 1; i++)
{
if (hdr[i] == '\r' && hdr[i + 1] == '\n')
return i - offset;
}
return -1;
}
}

View File

@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ZB.MOM.NatsNet.Server.Tests")]
[assembly: InternalsVisibleTo("ZB.MOM.NatsNet.Server.IntegrationTests")]

View File

@@ -0,0 +1,80 @@
// 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/parser.go and server/client.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Protocol;
/// <summary>
/// Interface for the protocol handler callbacks invoked by <see cref="ProtocolParser.Parse"/>.
/// Decouples the state machine from the client implementation.
/// The client connection will implement this interface in later sessions.
/// </summary>
public interface IProtocolHandler
{
// ---- Dynamic connection state ----
bool IsMqtt { get; }
bool Trace { get; }
bool HasMappings { get; }
bool IsAwaitingAuth { get; }
/// <summary>
/// Attempts to register the no-auth user for this connection.
/// Returns true if a no-auth user was found and registered (allowing parse to continue).
/// </summary>
bool TryRegisterNoAuthUser();
/// <summary>
/// Returns true if this is a gateway inbound connection that has not yet received CONNECT.
/// </summary>
bool IsGatewayInboundNotConnected { get; }
// ---- Protocol action handlers ----
Exception? ProcessConnect(byte[] arg);
Exception? ProcessInfo(byte[] arg);
void ProcessPing();
void ProcessPong();
void ProcessErr(string arg);
// ---- Sub/unsub handlers (kind-specific) ----
Exception? ProcessClientSub(byte[] arg);
Exception? ProcessClientUnsub(byte[] arg);
Exception? ProcessRemoteSub(byte[] arg, bool isLeaf);
Exception? ProcessRemoteUnsub(byte[] arg, bool isLeafUnsub);
Exception? ProcessGatewayRSub(byte[] arg);
Exception? ProcessGatewayRUnsub(byte[] arg);
Exception? ProcessLeafSub(byte[] arg);
Exception? ProcessLeafUnsub(byte[] arg);
Exception? ProcessAccountSub(byte[] arg);
void ProcessAccountUnsub(byte[] arg);
// ---- Message processing ----
void ProcessInboundMsg(byte[] msg);
bool SelectMappedSubject();
// ---- Tracing ----
void TraceInOp(string name, byte[]? arg);
void TraceMsg(byte[] msg);
// ---- Error handling ----
void SendErr(string msg);
void AuthViolation();
void CloseConnection(int reason);
string KindString();
}

View File

@@ -0,0 +1,171 @@
// 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/parser.go in the NATS server Go source.
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.Protocol;
/// <summary>
/// Parser state machine states.
/// Mirrors the Go <c>parserState</c> const block in parser.go (79 states).
/// </summary>
public enum ParserState
{
OpStart = 0,
OpPlus,
OpPlusO,
OpPlusOk,
OpMinus,
OpMinusE,
OpMinusEr,
OpMinusErr,
OpMinusErrSpc,
MinusErrArg,
OpC,
OpCo,
OpCon,
OpConn,
OpConne,
OpConnec,
OpConnect,
ConnectArg,
OpH,
OpHp,
OpHpu,
OpHpub,
OpHpubSpc,
HpubArg,
OpHm,
OpHms,
OpHmsg,
OpHmsgSpc,
HmsgArg,
OpP,
OpPu,
OpPub,
OpPubSpc,
PubArg,
OpPi,
OpPin,
OpPing,
OpPo,
OpPon,
OpPong,
MsgPayload,
MsgEndR,
MsgEndN,
OpS,
OpSu,
OpSub,
OpSubSpc,
SubArg,
OpA,
OpAsub,
OpAsubSpc,
AsubArg,
OpAusub,
OpAusubSpc,
AusubArg,
OpL,
OpLs,
OpR,
OpRs,
OpU,
OpUn,
OpUns,
OpUnsu,
OpUnsub,
OpUnsubSpc,
UnsubArg,
OpM,
OpMs,
OpMsg,
OpMsgSpc,
MsgArg,
OpI,
OpIn,
OpInf,
OpInfo,
InfoArg,
}
/// <summary>
/// Parsed publish/message arguments.
/// Mirrors Go <c>pubArg</c> struct in parser.go.
/// </summary>
public sealed class PublishArgument
{
public byte[]? Arg { get; set; }
public byte[]? PaCache { get; set; }
public byte[]? Origin { get; set; }
public byte[]? Account { get; set; }
public byte[]? Subject { get; set; }
public byte[]? Deliver { get; set; }
public byte[]? Mapped { get; set; }
public byte[]? Reply { get; set; }
public byte[]? SizeBytes { get; set; }
public byte[]? HeaderBytes { get; set; }
public List<byte[]>? Queues { get; set; }
public int Size { get; set; }
public int HeaderSize { get; set; } = -1;
public bool Delivered { get; set; }
/// <summary>Resets all fields to their defaults.</summary>
public void Reset()
{
Arg = null;
PaCache = null;
Origin = null;
Account = null;
Subject = null;
Deliver = null;
Mapped = null;
Reply = null;
SizeBytes = null;
HeaderBytes = null;
Queues = null;
Size = 0;
HeaderSize = -1;
Delivered = false;
}
}
/// <summary>
/// Holds the parser state for a single connection.
/// Mirrors Go <c>parseState</c> struct embedded in <c>client</c>.
/// </summary>
public sealed class ParseContext
{
// ---- Parser state ----
public ParserState State { get; set; }
public byte Op { get; set; }
public int ArgStart { get; set; }
public int Drop { get; set; }
public PublishArgument Pa { get; } = new();
public byte[]? ArgBuf { get; set; }
public byte[]? MsgBuf { get; set; }
// ---- Connection-level properties (set once at creation) ----
public ClientKind Kind { get; set; }
public int MaxControlLine { get; set; } = ServerConstants.MaxControlLineSize;
public int MaxPayload { get; set; } = -1;
public bool HasHeaders { get; set; }
// ---- Internal scratch buffer ----
internal byte[] Scratch { get; } = new byte[ServerConstants.MaxControlLineSize];
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,604 @@
// Copyright 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/client_proxyproto.go in the NATS server Go source.
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text;
namespace ZB.MOM.NatsNet.Server.Protocol;
// ============================================================================
// Proxy Protocol v2 constants
// ============================================================================
/// <summary>
/// PROXY protocol v1 and v2 constants.
/// Mirrors the const blocks in server/client_proxyproto.go.
/// </summary>
internal static class ProxyProtoConstants
{
// v2 signature (12 bytes)
internal const string V2Sig = "\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A";
// Version and command byte masks
internal const byte VerMask = 0xF0;
internal const byte Ver2 = 0x20;
internal const byte CmdMask = 0x0F;
internal const byte CmdLocal = 0x00;
internal const byte CmdProxy = 0x01;
// Address family and protocol masks
internal const byte FamilyMask = 0xF0;
internal const byte FamilyUnspec = 0x00;
internal const byte FamilyInet = 0x10;
internal const byte FamilyInet6 = 0x20;
internal const byte FamilyUnix = 0x30;
internal const byte ProtoMask = 0x0F;
internal const byte ProtoUnspec = 0x00;
internal const byte ProtoStream = 0x01;
internal const byte ProtoDatagram = 0x02;
// Address sizes
internal const int AddrSizeIPv4 = 12; // 4+4+2+2
internal const int AddrSizeIPv6 = 36; // 16+16+2+2
// Fixed v2 header size: 12 (sig) + 1 (ver/cmd) + 1 (fam/proto) + 2 (addr len)
internal const int V2HeaderSize = 16;
// Timeout for reading PROXY protocol header
internal static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5);
// v1 constants
internal const string V1Prefix = "PROXY ";
internal const int V1MaxLineLen = 107;
internal const string V1TCP4 = "TCP4";
internal const string V1TCP6 = "TCP6";
internal const string V1Unknown = "UNKNOWN";
}
// ============================================================================
// Well-known errors
// ============================================================================
/// <summary>
/// Well-known PROXY protocol errors.
/// Mirrors errProxyProtoInvalid, errProxyProtoUnsupported, etc. in client_proxyproto.go.
/// </summary>
public static class ProxyProtoErrors
{
public static readonly Exception Invalid = new InvalidDataException("invalid PROXY protocol header");
public static readonly Exception Unsupported = new NotSupportedException("unsupported PROXY protocol feature");
public static readonly Exception Timeout = new TimeoutException("timeout reading PROXY protocol header");
public static readonly Exception Unrecognized = new InvalidDataException("unrecognized PROXY protocol format");
}
// ============================================================================
// ProxyProtocolAddress
// ============================================================================
/// <summary>
/// Address information extracted from a PROXY protocol header.
/// Mirrors Go <c>proxyProtoAddr</c>.
/// </summary>
public sealed class ProxyProtocolAddress
{
public IPAddress SrcIp { get; }
public ushort SrcPort { get; }
public IPAddress DstIp { get; }
public ushort DstPort { get; }
internal ProxyProtocolAddress(IPAddress srcIp, ushort srcPort, IPAddress dstIp, ushort dstPort)
{
SrcIp = srcIp;
SrcPort = srcPort;
DstIp = dstIp;
DstPort = dstPort;
}
/// <summary>Returns "srcIP:srcPort". Mirrors <c>proxyProtoAddr.String()</c>.</summary>
public string String() => FormatEndpoint(SrcIp, SrcPort);
/// <summary>Returns "tcp4" or "tcp6". Mirrors <c>proxyProtoAddr.Network()</c>.</summary>
public string Network() => SrcIp.IsIPv4MappedToIPv6 || SrcIp.AddressFamily == AddressFamily.InterNetwork
? "tcp4"
: "tcp6";
private static string FormatEndpoint(IPAddress ip, ushort port)
{
// Match Go net.JoinHostPort — wraps IPv6 in brackets.
var addr = ip.AddressFamily == AddressFamily.InterNetworkV6
? $"[{ip}]"
: ip.ToString();
return $"{addr}:{port}";
}
}
// ============================================================================
// ProxyProtocolConnection
// ============================================================================
/// <summary>
/// Wraps a <see cref="Stream"/>/<see cref="Socket"/> to override the remote endpoint
/// with the address extracted from the PROXY protocol header.
/// Mirrors Go <c>proxyConn</c>.
/// </summary>
public sealed class ProxyProtocolConnection
{
private readonly Stream _inner;
/// <summary>The underlying connection stream.</summary>
public Stream InnerStream => _inner;
/// <summary>The proxied remote address (extracted from the header).</summary>
public ProxyProtocolAddress RemoteAddress { get; }
internal ProxyProtocolConnection(Stream inner, ProxyProtocolAddress remoteAddr)
{
_inner = inner;
RemoteAddress = remoteAddr;
}
}
// ============================================================================
// ProxyProtocolParser (static)
// ============================================================================
/// <summary>
/// Reads and parses PROXY protocol v1 and v2 headers from a <see cref="Stream"/>.
/// Mirrors the functions in server/client_proxyproto.go.
/// </summary>
public static class ProxyProtocolParser
{
// -------------------------------------------------------------------------
// Public entry points
// -------------------------------------------------------------------------
/// <summary>
/// Reads and parses a PROXY protocol (v1 or v2) header from <paramref name="stream"/>.
/// Returns <c>null</c> for LOCAL/UNKNOWN health-check commands.
/// Mirrors Go <c>readProxyProtoHeader</c>.
/// </summary>
public static async Task<ProxyProtocolAddress?> ReadProxyProtoHeaderAsync(
Stream stream,
CancellationToken cancellationToken = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(ProxyProtoConstants.ReadTimeout);
var ct = cts.Token;
// Detect version by reading first 6 bytes.
var (version, firstBytes, err) = await DetectVersionAsync(stream, ct).ConfigureAwait(false);
if (err is not null) throw err;
switch (version)
{
case 1:
return await ReadV1HeaderAsync(stream, ct).ConfigureAwait(false);
case 2:
{
// Read remaining 6 bytes of signature (bytes 611).
var remaining = new byte[6];
await ReadFullAsync(stream, remaining, ct).ConfigureAwait(false);
// Verify full signature.
var fullSig = Encoding.Latin1.GetString(firstBytes) + Encoding.Latin1.GetString(remaining);
if (fullSig != ProxyProtoConstants.V2Sig)
throw Wrap(ProxyProtoErrors.Invalid, "invalid signature");
// Read 4 bytes: ver/cmd, fam/proto, addr-len (2 bytes).
var header = new byte[4];
await ReadFullAsync(stream, header, ct).ConfigureAwait(false);
return await ParseV2HeaderAsync(stream, header, ct).ConfigureAwait(false);
}
default:
throw new InvalidOperationException($"unsupported PROXY protocol version: {version}");
}
}
/// <summary>
/// Reads and parses a PROXY protocol (v1 or v2) header, synchronously.
/// Returns <c>null</c> for LOCAL/UNKNOWN health-check commands.
/// Mirrors Go <c>readProxyProtoHeader</c>.
/// </summary>
public static ProxyProtocolAddress? ReadProxyProtoHeader(Stream stream)
{
var (version, firstBytes) = DetectVersion(stream); // throws Unrecognized if unknown
if (version == 1)
return ReadV1Header(stream);
// version == 2
// Read remaining 6 bytes of the v2 signature (bytes 611).
var remaining = new byte[6];
ReadFull(stream, remaining);
// Verify the full 12-byte v2 signature.
var fullSig = Encoding.Latin1.GetString(firstBytes) + Encoding.Latin1.GetString(remaining);
if (fullSig != ProxyProtoConstants.V2Sig)
throw Wrap(ProxyProtoErrors.Invalid, "invalid v2 signature");
// Read 4 bytes: ver/cmd, fam/proto, addr-len (2 bytes).
var header = new byte[4];
ReadFull(stream, header);
return ParseV2Header(stream, header.AsSpan());
}
/// <summary>
/// Reads a PROXY protocol v2 header from a raw byte buffer (test-friendly synchronous version).
/// Mirrors Go <c>readProxyProtoV2Header</c>.
/// </summary>
public static ProxyProtocolAddress? ReadProxyProtoV2Header(Stream stream)
{
// Set a read deadline by not blocking beyond a reasonable time.
// In the synchronous version we rely on a cancellation token internally.
using var cts = new CancellationTokenSource(ProxyProtoConstants.ReadTimeout);
// Read fixed header (16 bytes).
var header = new byte[ProxyProtoConstants.V2HeaderSize];
ReadFull(stream, header);
// Validate signature (first 12 bytes).
if (Encoding.Latin1.GetString(header, 0, 12) != ProxyProtoConstants.V2Sig)
throw Wrap(ProxyProtoErrors.Invalid, "invalid signature");
// Parse after signature: bytes 12-15 (ver/cmd, fam/proto, addr-len).
return ParseV2Header(stream, header.AsSpan(12, 4));
}
// -------------------------------------------------------------------------
// Internal: version detection
// -------------------------------------------------------------------------
internal static async Task<(int version, byte[] firstBytes, Exception? err)> DetectVersionAsync(
Stream stream, CancellationToken ct)
{
var buf = new byte[6];
try
{
await ReadFullAsync(stream, buf, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
return (0, buf, new IOException("failed to read protocol version", ex));
}
var s = Encoding.Latin1.GetString(buf);
if (s == ProxyProtoConstants.V1Prefix)
return (1, buf, null);
if (s == ProxyProtoConstants.V2Sig[..6])
return (2, buf, null);
return (0, buf, ProxyProtoErrors.Unrecognized);
}
/// <summary>
/// Synchronous version of version detection — used by test code.
/// Mirrors Go <c>detectProxyProtoVersion</c>.
/// </summary>
internal static (int version, byte[] firstBytes) DetectVersion(Stream stream)
{
var buf = new byte[6];
ReadFull(stream, buf);
var s = Encoding.Latin1.GetString(buf);
if (s == ProxyProtoConstants.V1Prefix)
return (1, buf);
if (s == ProxyProtoConstants.V2Sig[..6])
return (2, buf);
throw ProxyProtoErrors.Unrecognized;
}
// -------------------------------------------------------------------------
// Internal: v1 parser
// -------------------------------------------------------------------------
internal static async Task<ProxyProtocolAddress?> ReadV1HeaderAsync(Stream stream, CancellationToken ct)
{
// "PROXY " prefix was already consumed (6 bytes).
int maxRemaining = ProxyProtoConstants.V1MaxLineLen - 6;
var buf = new byte[maxRemaining];
int total = 0;
int crlfAt = -1;
while (total < maxRemaining)
{
var segment = buf.AsMemory(total);
int n = await stream.ReadAsync(segment, ct).ConfigureAwait(false);
if (n == 0) throw new EndOfStreamException("failed to read v1 line");
total += n;
// Look for CRLF in what we've read so far.
for (int i = 0; i < total - 1; i++)
{
if (buf[i] == '\r' && buf[i + 1] == '\n')
{
crlfAt = i;
break;
}
}
if (crlfAt >= 0) break;
}
if (crlfAt < 0)
throw Wrap(ProxyProtoErrors.Invalid, "v1 line too long");
return ParseV1Line(buf.AsSpan(0, crlfAt));
}
/// <summary>
/// Synchronous v1 parser. Mirrors Go <c>readProxyProtoV1Header</c>.
/// </summary>
internal static ProxyProtocolAddress? ReadV1Header(Stream stream)
{
int maxRemaining = ProxyProtoConstants.V1MaxLineLen - 6;
var buf = new byte[maxRemaining];
int total = 0;
int crlfAt = -1;
while (total < maxRemaining)
{
int n = stream.Read(buf, total, maxRemaining - total);
if (n == 0) throw new EndOfStreamException("failed to read v1 line");
total += n;
for (int i = 0; i < total - 1; i++)
{
if (buf[i] == '\r' && buf[i + 1] == '\n')
{
crlfAt = i;
break;
}
}
if (crlfAt >= 0) break;
}
if (crlfAt < 0)
throw Wrap(ProxyProtoErrors.Invalid, "v1 line too long");
return ParseV1Line(buf.AsSpan(0, crlfAt));
}
private static ProxyProtocolAddress? ParseV1Line(ReadOnlySpan<byte> line)
{
var text = Encoding.ASCII.GetString(line).Trim();
var parts = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 1)
throw Wrap(ProxyProtoErrors.Invalid, "invalid v1 format");
// UNKNOWN is a health-check (like LOCAL in v2).
if (parts[0] == ProxyProtoConstants.V1Unknown)
return null;
if (parts.Length != 5)
throw Wrap(ProxyProtoErrors.Invalid, "invalid v1 format");
var protocol = parts[0];
if (!IPAddress.TryParse(parts[1], out var srcIp) || !IPAddress.TryParse(parts[2], out var dstIp))
throw Wrap(ProxyProtoErrors.Invalid, "invalid address");
if (!ushort.TryParse(parts[3], out var srcPort))
throw new FormatException("invalid source port");
if (!ushort.TryParse(parts[4], out var dstPort))
throw new FormatException("invalid dest port");
// Validate protocol vs IP version.
bool isIpv4 = srcIp.AddressFamily == AddressFamily.InterNetwork;
if (protocol == ProxyProtoConstants.V1TCP4 && !isIpv4)
throw Wrap(ProxyProtoErrors.Invalid, "TCP4 with IPv6 address");
if (protocol == ProxyProtoConstants.V1TCP6 && isIpv4)
throw Wrap(ProxyProtoErrors.Invalid, "TCP6 with IPv4 address");
if (protocol != ProxyProtoConstants.V1TCP4 && protocol != ProxyProtoConstants.V1TCP6)
throw Wrap(ProxyProtoErrors.Invalid, $"invalid protocol {protocol}");
return new ProxyProtocolAddress(srcIp, srcPort, dstIp, dstPort);
}
// -------------------------------------------------------------------------
// Internal: v2 parser
// -------------------------------------------------------------------------
internal static async Task<ProxyProtocolAddress?> ParseV2HeaderAsync(
Stream stream, byte[] header, CancellationToken ct)
{
return ParseV2Header(stream, header.AsSpan());
}
/// <summary>
/// Parses PROXY protocol v2 after the signature has been validated.
/// <paramref name="header"/> is the 4 bytes: ver/cmd, fam/proto, addr-len (2 bytes).
/// Mirrors Go <c>parseProxyProtoV2Header</c>.
/// </summary>
internal static ProxyProtocolAddress? ParseV2Header(Stream stream, ReadOnlySpan<byte> header)
{
byte verCmd = header[0];
byte version = (byte)(verCmd & ProxyProtoConstants.VerMask);
byte command = (byte)(verCmd & ProxyProtoConstants.CmdMask);
if (version != ProxyProtoConstants.Ver2)
throw Wrap(ProxyProtoErrors.Invalid, $"invalid version 0x{version:X2}");
byte famProto = header[1];
byte family = (byte)(famProto & ProxyProtoConstants.FamilyMask);
byte proto = (byte)(famProto & ProxyProtoConstants.ProtoMask);
ushort addrLen = BinaryPrimitives.ReadUInt16BigEndian(header[2..]);
// LOCAL command — health check.
if (command == ProxyProtoConstants.CmdLocal)
{
if (addrLen > 0)
DiscardBytes(stream, addrLen);
return null;
}
if (command != ProxyProtoConstants.CmdProxy)
throw new InvalidDataException($"unknown PROXY protocol command: 0x{command:X2}");
if (proto != ProxyProtoConstants.ProtoStream)
throw Wrap(ProxyProtoErrors.Unsupported, "only STREAM protocol supported");
switch (family)
{
case ProxyProtoConstants.FamilyInet:
return ParseIPv4Addr(stream, addrLen);
case ProxyProtoConstants.FamilyInet6:
return ParseIPv6Addr(stream, addrLen);
case ProxyProtoConstants.FamilyUnspec:
if (addrLen > 0)
DiscardBytes(stream, addrLen);
return null;
default:
throw Wrap(ProxyProtoErrors.Unsupported, $"unsupported address family 0x{family:X2}");
}
}
/// <summary>
/// Parses IPv4 address data.
/// Mirrors Go <c>parseIPv4Addr</c>.
/// </summary>
internal static ProxyProtocolAddress ParseIPv4Addr(Stream stream, ushort addrLen)
{
if (addrLen < ProxyProtoConstants.AddrSizeIPv4)
throw new InvalidDataException($"IPv4 address data too short: {addrLen} bytes");
var data = new byte[addrLen];
ReadFull(stream, data);
var srcIp = new IPAddress(data[0..4]);
var dstIp = new IPAddress(data[4..8]);
var srcPort = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(8, 2));
var dstPort = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(10, 2));
return new ProxyProtocolAddress(srcIp, srcPort, dstIp, dstPort);
}
/// <summary>
/// Parses IPv6 address data.
/// Mirrors Go <c>parseIPv6Addr</c>.
/// </summary>
internal static ProxyProtocolAddress ParseIPv6Addr(Stream stream, ushort addrLen)
{
if (addrLen < ProxyProtoConstants.AddrSizeIPv6)
throw new InvalidDataException($"IPv6 address data too short: {addrLen} bytes");
var data = new byte[addrLen];
ReadFull(stream, data);
var srcIp = new IPAddress(data[0..16]);
var dstIp = new IPAddress(data[16..32]);
var srcPort = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(32, 2));
var dstPort = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(34, 2));
return new ProxyProtocolAddress(srcIp, srcPort, dstIp, dstPort);
}
// -------------------------------------------------------------------------
// I/O helpers
// -------------------------------------------------------------------------
/// <summary>
/// Fills <paramref name="buf"/> completely, throwing <see cref="EndOfStreamException"/>
/// (wrapping as <see cref="IOException"/> with <see cref="UnexpectedEofException"/>)
/// on short reads.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void ReadFull(Stream stream, byte[] buf)
{
int total = 0;
while (total < buf.Length)
{
int n = stream.Read(buf, total, buf.Length - total);
if (n == 0)
throw new IOException("unexpected EOF", new EndOfStreamException());
total += n;
}
}
internal static async Task ReadFullAsync(Stream stream, byte[] buf, CancellationToken ct)
{
int total = 0;
while (total < buf.Length)
{
int n = await stream.ReadAsync(buf.AsMemory(total), ct).ConfigureAwait(false);
if (n == 0)
throw new IOException("unexpected EOF", new EndOfStreamException());
total += n;
}
}
private static void DiscardBytes(Stream stream, int count)
{
var discard = new byte[count];
ReadFull(stream, discard);
}
private static Exception Wrap(Exception sentinel, string detail)
{
// Create a new exception that wraps the sentinel but carries the extra detail.
// The sentinel remains identifiable via the Message prefix (checked in tests with IsAssignableTo).
return new InvalidDataException($"{sentinel.Message}: {detail}", sentinel);
}
}
// ============================================================================
// StreamAdapter — wraps a byte array as a Stream (for test convenience)
// ============================================================================
/// <summary>
/// Minimal read-only <see cref="Stream"/> backed by a byte array.
/// Used by test helpers to feed proxy protocol bytes into the parser.
/// </summary>
internal sealed class ByteArrayStream : Stream
{
private readonly byte[] _data;
private int _pos;
public ByteArrayStream(byte[] data) { _data = data; }
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => _data.Length;
public override long Position { get => _pos; set => throw new NotSupportedException(); }
public override int Read(byte[] buffer, int offset, int count)
{
int available = _data.Length - _pos;
if (available <= 0) return 0;
int toCopy = Math.Min(count, available);
Buffer.BlockCopy(_data, _pos, buffer, offset, toCopy);
_pos += toCopy;
return toCopy;
}
public override void Flush() => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public void SetReadTimeout(int timeout) { }
public void SetWriteTimeout(int timeout) { }
}

View File

@@ -0,0 +1,228 @@
// 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/const.go in the NATS server Go source.
using System.Reflection;
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// Server-wide constants and version information.
/// Mirrors server/const.go.
/// </summary>
public static class ServerConstants
{
// Server version — mirrors VERSION in const.go.
public const string Version = "2.14.0-dev";
// Protocol version — mirrors PROTO in const.go.
public const int Proto = 1;
// Default port for client connections — mirrors DEFAULT_PORT.
public const int DefaultPort = 4222;
// Sentinel port value that triggers a random port selection — mirrors RANDOM_PORT.
public const int RandomPort = -1;
// Default bind address — mirrors DEFAULT_HOST.
public const string DefaultHost = "0.0.0.0";
// Maximum allowed control line size (4 KB) — mirrors MAX_CONTROL_LINE_SIZE.
public const int MaxControlLineSize = 4096;
// Maximum allowed payload size (1 MB) — mirrors MAX_PAYLOAD_SIZE.
public const int MaxPayloadSize = 1024 * 1024;
// Payload size above which the server warns — mirrors MAX_PAYLOAD_MAX_SIZE.
public const int MaxPayloadMaxSize = 8 * 1024 * 1024;
// Maximum outbound pending bytes per client (64 MB) — mirrors MAX_PENDING_SIZE.
public const int MaxPendingSize = 64 * 1024 * 1024;
// Default maximum connections allowed (64 K) — mirrors DEFAULT_MAX_CONNECTIONS.
public const int DefaultMaxConnections = 64 * 1024;
// TLS handshake timeout — mirrors TLS_TIMEOUT.
public static readonly TimeSpan TlsTimeout = TimeSpan.FromSeconds(2);
// Fallback delay before sending INFO when using TLSHandshakeFirst
// — mirrors DEFAULT_TLS_HANDSHAKE_FIRST_FALLBACK_DELAY.
public static readonly TimeSpan DefaultTlsHandshakeFirstFallbackDelay = TimeSpan.FromMilliseconds(50);
// Auth timeout — mirrors AUTH_TIMEOUT.
public static readonly TimeSpan AuthTimeout = TimeSpan.FromSeconds(2);
// How often pings are sent — mirrors DEFAULT_PING_INTERVAL.
public static readonly TimeSpan DefaultPingInterval = TimeSpan.FromMinutes(2);
// Maximum pings outstanding before disconnect — mirrors DEFAULT_PING_MAX_OUT.
public const int DefaultPingMaxOut = 2;
// CR LF end-of-line — mirrors CR_LF.
public const string CrLf = "\r\n";
// Length of CR_LF — mirrors LEN_CR_LF.
public const int LenCrLf = 2;
// Write/flush deadline — mirrors DEFAULT_FLUSH_DEADLINE.
public static readonly TimeSpan DefaultFlushDeadline = TimeSpan.FromSeconds(10);
// Default monitoring port — mirrors DEFAULT_HTTP_PORT.
public const int DefaultHttpPort = 8222;
// Default monitoring base path — mirrors DEFAULT_HTTP_BASE_PATH.
public const string DefaultHttpBasePath = "/";
// Minimum sleep on temporary accept errors — mirrors ACCEPT_MIN_SLEEP.
public static readonly TimeSpan AcceptMinSleep = TimeSpan.FromMilliseconds(10);
// Maximum sleep on temporary accept errors — mirrors ACCEPT_MAX_SLEEP.
public static readonly TimeSpan AcceptMaxSleep = TimeSpan.FromSeconds(1);
// Route solicitation interval — mirrors DEFAULT_ROUTE_CONNECT.
public static readonly TimeSpan DefaultRouteConnect = TimeSpan.FromSeconds(1);
// Maximum route solicitation interval — mirrors DEFAULT_ROUTE_CONNECT_MAX.
public static readonly TimeSpan DefaultRouteConnectMax = TimeSpan.FromSeconds(30);
// Route reconnect delay — mirrors DEFAULT_ROUTE_RECONNECT.
public static readonly TimeSpan DefaultRouteReconnect = TimeSpan.FromSeconds(1);
// Route dial timeout — mirrors DEFAULT_ROUTE_DIAL.
public static readonly TimeSpan DefaultRouteDial = TimeSpan.FromSeconds(1);
// Default route pool size — mirrors DEFAULT_ROUTE_POOL_SIZE.
public const int DefaultRoutePoolSize = 3;
// LeafNode reconnect interval — mirrors DEFAULT_LEAF_NODE_RECONNECT.
public static readonly TimeSpan DefaultLeafNodeReconnect = TimeSpan.FromSeconds(1);
// TLS timeout for leaf nodes — mirrors DEFAULT_LEAF_TLS_TIMEOUT.
public static readonly TimeSpan DefaultLeafTlsTimeout = TimeSpan.FromSeconds(2);
// Proto snippet size for parse error display — mirrors PROTO_SNIPPET_SIZE.
public const int ProtoSnippetSize = 32;
// Max control line snippet size for error display — mirrors MAX_CONTROL_LINE_SNIPPET_SIZE.
public const int MaxControlLineSnippetSize = 128;
// Maximum MSG proto argument count — mirrors MAX_MSG_ARGS.
public const int MaxMsgArgs = 4;
// Maximum RMSG proto argument count — mirrors MAX_RMSG_ARGS.
public const int MaxRMsgArgs = 6;
// Maximum HMSG proto argument count — mirrors MAX_HMSG_ARGS.
public const int MaxHMsgArgs = 7;
// Maximum PUB proto argument count — mirrors MAX_PUB_ARGS.
public const int MaxPubArgs = 3;
// Maximum HPUB proto argument count — mirrors MAX_HPUB_ARGS.
public const int MaxHPubArgs = 4;
// Maximum RS+/LS+ proto argument count — mirrors MAX_RSUB_ARGS.
public const int MaxRSubArgs = 6;
// Maximum closed connections retained — mirrors DEFAULT_MAX_CLOSED_CLIENTS.
public const int DefaultMaxClosedClients = 10000;
// Lame duck spread duration — mirrors DEFAULT_LAME_DUCK_DURATION.
public static readonly TimeSpan DefaultLameDuckDuration = TimeSpan.FromMinutes(2);
// Lame duck grace period — mirrors DEFAULT_LAME_DUCK_GRACE_PERIOD.
public static readonly TimeSpan DefaultLameDuckGracePeriod = TimeSpan.FromSeconds(10);
// Leaf node INFO wait — mirrors DEFAULT_LEAFNODE_INFO_WAIT.
public static readonly TimeSpan DefaultLeafNodeInfoWait = TimeSpan.FromSeconds(1);
// Default leaf node port — mirrors DEFAULT_LEAFNODE_PORT.
public const int DefaultLeafNodePort = 7422;
// Connect error report threshold — mirrors DEFAULT_CONNECT_ERROR_REPORTS.
public const int DefaultConnectErrorReports = 3600;
// Reconnect error report threshold — mirrors DEFAULT_RECONNECT_ERROR_REPORTS.
public const int DefaultReconnectErrorReports = 1;
// RTT measurement interval — mirrors DEFAULT_RTT_MEASUREMENT_INTERVAL.
public static readonly TimeSpan DefaultRttMeasurementInterval = TimeSpan.FromHours(1);
// Default allowed response max messages — mirrors DEFAULT_ALLOW_RESPONSE_MAX_MSGS.
public const int DefaultAllowResponseMaxMsgs = 1;
// Default allowed response expiration — mirrors DEFAULT_ALLOW_RESPONSE_EXPIRATION.
public static readonly TimeSpan DefaultAllowResponseExpiration = TimeSpan.FromMinutes(2);
// Default service export response threshold — mirrors DEFAULT_SERVICE_EXPORT_RESPONSE_THRESHOLD.
public static readonly TimeSpan DefaultServiceExportResponseThreshold = TimeSpan.FromMinutes(2);
// Default service latency sampling rate — mirrors DEFAULT_SERVICE_LATENCY_SAMPLING.
public const int DefaultServiceLatencySampling = 100;
// Default system account name — mirrors DEFAULT_SYSTEM_ACCOUNT.
public const string DefaultSystemAccount = "$SYS";
// Default global account name — mirrors DEFAULT_GLOBAL_ACCOUNT.
public const string DefaultGlobalAccount = "$G";
// Default account fetch timeout — mirrors DEFAULT_ACCOUNT_FETCH_TIMEOUT.
public static readonly TimeSpan DefaultAccountFetchTimeout = TimeSpan.FromMilliseconds(1900);
// VCS commit hash embedded at build time, shortened to 7 chars — mirrors gitCommit.
// Populated from AssemblyInformationalVersion metadata if available.
public static readonly string GitCommit;
static ServerConstants()
{
// Mirror const.go init(): extract VCS revision from build info.
// In .NET we read the AssemblyInformationalVersion attribute which
// is typically set to the semantic version + commit hash by dotnet publish.
var infoVersion = typeof(ServerConstants).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
if (infoVersion != null)
{
// Convention: "1.2.3+abcdefg" or "1.2.3-dev+abcdefg"
var plusIdx = infoVersion.IndexOf('+');
if (plusIdx >= 0)
{
var rev = infoVersion[(plusIdx + 1)..];
GitCommit = FormatRevision(rev);
return;
}
}
GitCommit = string.Empty;
}
/// <summary>
/// Truncates a VCS revision string to 7 characters for display.
/// Mirrors <c>formatRevision</c> in const.go.
/// </summary>
public static string FormatRevision(string revision) =>
revision.Length >= 7 ? revision[..7] : revision;
}
/// <summary>
/// Server control commands — mirrors the <c>Command</c> type in const.go.
/// </summary>
public enum ServerCommand
{
Stop,
Quit,
Reopen,
Reload,
}

View File

@@ -0,0 +1,450 @@
// 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/errors.go in the NATS server Go source.
// Note: errorsUnwrap() and ErrorIs() are Go 1.12 compat shims — .NET has
// Exception.InnerException and errors.Is() equivalents built in;
// both are represented here for completeness but map to built-in .NET patterns.
namespace ZB.MOM.NatsNet.Server;
// -------------------------------------------------------------------------
// Sentinel error values (mirrors package-level var block in errors.go)
// -------------------------------------------------------------------------
/// <summary>
/// Standard NATS server error sentinels.
/// Mirrors the package-level <c>var</c> error block in server/errors.go.
/// </summary>
public static class ServerErrors
{
public static readonly Exception ErrConnectionClosed =
new InvalidOperationException("connection closed");
public static readonly Exception ErrAuthentication =
new InvalidOperationException("authentication error");
// Alias used by ClientConnection.AuthViolation(); mirrors Go's ErrAuthorization.
public static readonly Exception ErrAuthorization =
new InvalidOperationException("Authorization Violation");
public static readonly Exception ErrAuthTimeout =
new InvalidOperationException("authentication timeout");
public static readonly Exception ErrAuthExpired =
new InvalidOperationException("authentication expired");
public static readonly Exception ErrAuthProxyNotTrusted =
new InvalidOperationException("proxy is not trusted");
public static readonly Exception ErrAuthProxyRequired =
new InvalidOperationException("proxy connection required");
public static readonly Exception ErrMaxPayload =
new InvalidOperationException("maximum payload exceeded");
public static readonly Exception ErrMaxControlLine =
new InvalidOperationException("maximum control line exceeded");
public static readonly Exception ErrReservedPublishSubject =
new InvalidOperationException("reserved internal subject");
public static readonly Exception ErrBadPublishSubject =
new InvalidOperationException("invalid publish subject");
public static readonly Exception ErrBadSubject =
new InvalidOperationException("invalid subject");
public static readonly Exception ErrBadQualifier =
new InvalidOperationException("bad qualifier");
public static readonly Exception ErrBadClientProtocol =
new InvalidOperationException("invalid client protocol");
public static readonly Exception ErrTooManyConnections =
new InvalidOperationException("maximum connections exceeded");
public static readonly Exception ErrTooManyAccountConnections =
new InvalidOperationException("maximum account active connections exceeded");
public static readonly Exception ErrLeafNodeLoop =
new InvalidOperationException("leafnode loop detected");
public static readonly Exception ErrTooManySubs =
new InvalidOperationException("maximum subscriptions exceeded");
public static readonly Exception ErrTooManySubTokens =
new InvalidOperationException("subject has exceeded number of tokens limit");
public static readonly Exception ErrClientConnectedToRoutePort =
new InvalidOperationException("attempted to connect to route port");
public static readonly Exception ErrClientConnectedToLeafNodePort =
new InvalidOperationException("attempted to connect to leaf node port");
public static readonly Exception ErrLeafNodeHasSameClusterName =
new InvalidOperationException("remote leafnode has same cluster name");
public static readonly Exception ErrLeafNodeDisabled =
new InvalidOperationException("leafnodes disabled");
public static readonly Exception ErrConnectedToWrongPort =
new InvalidOperationException("attempted to connect to wrong port");
public static readonly Exception ErrAccountExists =
new InvalidOperationException("account exists");
public static readonly Exception ErrBadAccount =
new InvalidOperationException("bad account");
public static readonly Exception ErrReservedAccount =
new InvalidOperationException("reserved account");
public static readonly Exception ErrMissingAccount =
new InvalidOperationException("account missing");
public static readonly Exception ErrMissingService =
new InvalidOperationException("service missing");
public static readonly Exception ErrBadServiceType =
new InvalidOperationException("bad service response type");
public static readonly Exception ErrBadSampling =
new InvalidOperationException("bad sampling percentage, should be 1-100");
public static readonly Exception ErrAccountValidation =
new InvalidOperationException("account validation failed");
public static readonly Exception ErrAccountExpired =
new InvalidOperationException("account expired");
public static readonly Exception ErrNoAccountResolver =
new InvalidOperationException("account resolver missing");
public static readonly Exception ErrAccountResolverUpdateTooSoon =
new InvalidOperationException("account resolver update too soon");
public static readonly Exception ErrAccountResolverSameClaims =
new InvalidOperationException("account resolver no new claims");
public static readonly Exception ErrStreamImportAuthorization =
new InvalidOperationException("stream import not authorized");
public static readonly Exception ErrStreamImportBadPrefix =
new InvalidOperationException("stream import prefix can not contain wildcard tokens");
public static readonly Exception ErrStreamImportDuplicate =
new InvalidOperationException("stream import already exists");
public static readonly Exception ErrServiceImportAuthorization =
new InvalidOperationException("service import not authorized");
public static readonly Exception ErrImportFormsCycle =
new InvalidOperationException("import forms a cycle");
public static readonly Exception ErrCycleSearchDepth =
new InvalidOperationException("search cycle depth exhausted");
public static readonly Exception ErrClientOrRouteConnectedToGatewayPort =
new InvalidOperationException("attempted to connect to gateway port");
public static readonly Exception ErrWrongGateway =
new InvalidOperationException("wrong gateway");
public static readonly Exception ErrGatewayNameHasSpaces =
new InvalidOperationException("gateway name cannot contain spaces");
public static readonly Exception ErrNoSysAccount =
new InvalidOperationException("system account not setup");
public static readonly Exception ErrRevocation =
new InvalidOperationException("credentials have been revoked");
public static readonly Exception ErrServerNotRunning =
new InvalidOperationException("server is not running");
public static readonly Exception ErrServerNameHasSpaces =
new InvalidOperationException("server name cannot contain spaces");
public static readonly Exception ErrBadMsgHeader =
new InvalidOperationException("bad message header detected");
public static readonly Exception ErrMsgHeadersNotSupported =
new InvalidOperationException("message headers not supported");
public static readonly Exception ErrNoRespondersRequiresHeaders =
new InvalidOperationException("no responders requires headers support");
public static readonly Exception ErrClusterNameConfigConflict =
new InvalidOperationException("cluster name conflicts between cluster and gateway definitions");
public static readonly Exception ErrClusterNameRemoteConflict =
new InvalidOperationException("cluster name from remote server conflicts");
public static readonly Exception ErrClusterNameHasSpaces =
new InvalidOperationException("cluster name cannot contain spaces");
public static readonly Exception ErrMalformedSubject =
new InvalidOperationException("malformed subject");
public static readonly Exception ErrSubscribePermissionViolation =
new InvalidOperationException("subscribe permission violation");
public static readonly Exception ErrNoTransforms =
new InvalidOperationException("no matching transforms available");
public static readonly Exception ErrCertNotPinned =
new InvalidOperationException("certificate not pinned");
public static readonly Exception ErrDuplicateServerName =
new InvalidOperationException("duplicate server name");
public static readonly Exception ErrMinimumVersionRequired =
new InvalidOperationException("minimum version required");
// Mapping destination errors — the Go source wraps ErrInvalidMappingDestination.
// In .NET we use a common base message and chain inner exceptions where needed.
public static readonly Exception ErrInvalidMappingDestination =
new InvalidOperationException("invalid mapping destination");
public static readonly Exception ErrInvalidMappingDestinationSubject =
new InvalidOperationException("invalid mapping destination: invalid transform");
public static readonly Exception ErrMappingDestinationNotUsingAllWildcards =
new InvalidOperationException("invalid mapping destination: not using all of the token wildcard(s)");
public static readonly Exception ErrUnknownMappingDestinationFunction =
new InvalidOperationException("invalid mapping destination: unknown function");
public static readonly Exception ErrMappingDestinationIndexOutOfRange =
new InvalidOperationException("invalid mapping destination: wildcard index out of range");
public static readonly Exception ErrMappingDestinationNotEnoughArgs =
new InvalidOperationException("invalid mapping destination: not enough arguments passed to the function");
public static readonly Exception ErrMappingDestinationInvalidArg =
new InvalidOperationException("invalid mapping destination: function argument is invalid or in the wrong format");
public static readonly Exception ErrMappingDestinationTooManyArgs =
new InvalidOperationException("invalid mapping destination: too many arguments passed to the function");
public static readonly Exception ErrMappingDestinationNotSupportedForImport =
new InvalidOperationException("invalid mapping destination: the only mapping function allowed for import transforms is {{Wildcard()}}");
}
// -------------------------------------------------------------------------
// mappingDestinationErr (mirrors server/errors.go)
// -------------------------------------------------------------------------
/// <summary>
/// An error type for subject mapping destination validation failures.
/// Mirrors <c>mappingDestinationErr</c> in server/errors.go.
/// </summary>
public sealed class MappingDestinationException : Exception
{
private readonly string _token;
public MappingDestinationException(string token, Exception inner)
: base($"{inner.Message} in {token}", inner)
{
_token = token;
}
// Is() in Go is implemented via IsInvalidMappingDestination below.
/// <summary>
/// Returns true when compared against <see cref="ServerErrors.ErrInvalidMappingDestination"/>.
/// Mirrors <c>mappingDestinationErr.Is</c>.
/// </summary>
public bool Is(Exception target) =>
ReferenceEquals(target, ServerErrors.ErrInvalidMappingDestination);
}
// -------------------------------------------------------------------------
// configErr / unknownConfigFieldErr / configWarningErr (mirrors server/errors.go)
// -------------------------------------------------------------------------
// Note: these types depend on a config-file token interface defined in the
// configuration parser (ported in session 03). Forward-declared here with the
// minimal interface needed for error formatting.
/// <summary>
/// Represents a source location within a configuration file.
/// Mirrors the <c>token</c> interface used by configErr in server/errors.go.
/// Full implementation is provided by the configuration parser (session 03).
/// </summary>
public interface IConfigToken
{
string SourceFile();
int Line();
int Position();
}
/// <summary>
/// A configuration parsing error with source location.
/// Mirrors <c>configErr</c> in server/errors.go.
/// </summary>
public class ConfigException : Exception
{
private readonly IConfigToken? _token;
public ConfigException(IConfigToken? token, string reason)
: base(reason)
{
_token = token;
}
/// <summary>Returns "file:line:col" source location, or empty string if no token.</summary>
public new string Source() =>
_token != null
? $"{_token.SourceFile()}:{_token.Line()}:{_token.Position()}"
: string.Empty;
public override string Message =>
_token != null ? $"{Source()}: {base.Message}" : base.Message;
}
/// <summary>
/// A configuration error for an unknown field (pedantic mode).
/// Mirrors <c>unknownConfigFieldErr</c> in server/errors.go.
/// </summary>
public sealed class UnknownConfigFieldException : ConfigException
{
private readonly string _field;
public UnknownConfigFieldException(IConfigToken token, string field)
: base(token, $"unknown field \"{field}\"")
{
_field = field;
}
}
/// <summary>
/// A configuration warning for invalid field usage (pedantic mode).
/// Mirrors <c>configWarningErr</c> in server/errors.go.
/// </summary>
public sealed class ConfigWarningException : ConfigException
{
private readonly string _field;
public ConfigWarningException(IConfigToken token, string field, string reason)
: base(token, $"invalid use of field \"{field}\": {reason}")
{
_field = field;
}
}
/// <summary>
/// Aggregates configuration warnings and hard errors from parsing.
/// Mirrors <c>processConfigErr</c> in server/errors.go.
/// </summary>
public sealed class ProcessConfigException : Exception
{
public IReadOnlyList<Exception> Warnings { get; }
public IReadOnlyList<Exception> Errors { get; }
public ProcessConfigException(IReadOnlyList<Exception> errors, IReadOnlyList<Exception> warnings)
: base(BuildMessage(errors, warnings))
{
Errors = errors;
Warnings = warnings;
}
private static string BuildMessage(IReadOnlyList<Exception> errors, IReadOnlyList<Exception> warnings)
{
var sb = new System.Text.StringBuilder();
foreach (var w in warnings) sb.AppendLine(w.Message);
foreach (var e in errors) sb.AppendLine(e.Message);
return sb.ToString();
}
}
// -------------------------------------------------------------------------
// errCtx — error with attached tracing context (mirrors server/errors.go)
// -------------------------------------------------------------------------
/// <summary>
/// Wraps an exception and attaches additional tracing context.
/// Context is not included in <see cref="Exception.Message"/> but is
/// accessible via <see cref="Context"/> and <see cref="UnpackIfErrorCtx"/>.
/// Mirrors <c>errCtx</c> in server/errors.go.
/// </summary>
public sealed class ErrorCtx : Exception
{
public string Ctx { get; }
public ErrorCtx(Exception inner, string ctx)
: base(inner.Message, inner)
{
Ctx = ctx;
}
/// <summary>
/// Returns the context string attached to this error.
/// Mirrors <c>errCtx.Context()</c>.
/// </summary>
public string Context() => Ctx;
}
/// <summary>
/// Factory and utilities for <see cref="ErrorCtx"/>.
/// Mirrors <c>NewErrorCtx</c>, <c>UnpackIfErrorCtx</c>, and <c>ErrorIs</c> in server/errors.go.
/// </summary>
public static class ErrorContextHelper
{
/// <summary>
/// Creates an <see cref="ErrorCtx"/> wrapping <paramref name="err"/> with formatted context.
/// Mirrors <c>NewErrorCtx</c>.
/// </summary>
public static Exception NewErrorCtx(Exception err, string format, params object[] args) =>
new ErrorCtx(err, string.Format(format, args));
/// <summary>
/// If <paramref name="err"/> is an <see cref="ErrorCtx"/>, returns
/// "original.Message: ctx" (recursively). Otherwise returns err.Message.
/// Mirrors <c>UnpackIfErrorCtx</c>.
/// </summary>
public static string UnpackIfErrorCtx(Exception err)
{
if (err is ErrorCtx ectx)
{
if (ectx.InnerException is ErrorCtx)
return $"{UnpackIfErrorCtx(ectx.InnerException!)}: {ectx.Ctx}";
return $"{ectx.InnerException!.Message}: {ectx.Ctx}";
}
return err.Message;
}
/// <summary>
/// Walks the inner exception chain checking reference equality and
/// <see cref="MappingDestinationException.Is"/> overrides.
/// In .NET, prefer <c>errors.Is</c>-equivalent patterns; this mirrors
/// the Go <c>ErrorIs</c> shim exactly.
/// Mirrors <c>ErrorIs</c>.
/// </summary>
public static bool ErrorIs(Exception? err, Exception? target)
{
if (err == null || target == null)
return ReferenceEquals(err, target);
var current = err;
while (current != null)
{
if (ReferenceEquals(current, target))
return true;
if (current is MappingDestinationException mde && mde.Is(target))
return true;
current = current.InnerException;
}
return false;
}
}

View File

@@ -0,0 +1,477 @@
// 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/opts.go in the NATS server Go source.
using System.Net.Security;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// Write timeout behavior policy.
/// Mirrors <c>WriteTimeoutPolicy</c> in opts.go.
/// </summary>
public enum WriteTimeoutPolicy : byte
{
Default = 0,
Close = 1,
Retry = 2,
}
/// <summary>
/// Store encryption cipher selection.
/// Mirrors <c>StoreCipher</c> in opts.go.
/// </summary>
public enum StoreCipher
{
ChaCha = 0,
Aes = 1,
NoCipher = 2,
}
/// <summary>
/// OCSP stapling mode.
/// Mirrors <c>OCSPMode</c> in opts.go.
/// </summary>
public enum OcspMode : byte
{
Auto = 0,
Always = 1,
Never = 2,
Must = 3,
}
/// <summary>
/// Set of pinned certificate SHA256 hashes (lowercase hex-encoded DER SubjectPublicKeyInfo).
/// Mirrors <c>PinnedCertSet</c> in opts.go.
/// </summary>
public class PinnedCertSet : HashSet<string>
{
public PinnedCertSet() : base(StringComparer.OrdinalIgnoreCase) { }
public PinnedCertSet(IEnumerable<string> collection) : base(collection, StringComparer.OrdinalIgnoreCase) { }
}
/// <summary>
/// Compression options for route/leaf connections.
/// Mirrors <c>CompressionOpts</c> in opts.go.
/// </summary>
public class CompressionOpts
{
public string Mode { get; set; } = string.Empty;
public List<TimeSpan> RttThresholds { get; set; } = [];
}
/// <summary>
/// Compression mode string constants.
/// </summary>
public static class CompressionModes
{
public const string Off = "off";
public const string Accept = "accept";
public const string S2Fast = "s2_fast";
public const string S2Better = "s2_better";
public const string S2Best = "s2_best";
public const string S2Auto = "s2_auto";
}
/// <summary>
/// TLS configuration parsed from config file.
/// Mirrors <c>TLSConfigOpts</c> in opts.go.
/// </summary>
public class TlsConfigOpts
{
public string CertFile { get; set; } = string.Empty;
public string KeyFile { get; set; } = string.Empty;
public string CaFile { get; set; } = string.Empty;
public bool Verify { get; set; }
public bool Insecure { get; set; }
public bool Map { get; set; }
public bool TlsCheckKnownUrls { get; set; }
public bool HandshakeFirst { get; set; }
public TimeSpan FallbackDelay { get; set; }
public double Timeout { get; set; }
public long RateLimit { get; set; }
public bool AllowInsecureCiphers { get; set; }
public List<SslProtocols> CurvePreferences { get; set; } = [];
public PinnedCertSet? PinnedCerts { get; set; }
public string CertMatch { get; set; } = string.Empty;
public bool CertMatchSkipInvalid { get; set; }
public List<string> CaCertsMatch { get; set; } = [];
public List<TlsCertPairOpt> Certificates { get; set; } = [];
public SslProtocols MinVersion { get; set; }
}
/// <summary>
/// Certificate and key file pair.
/// Mirrors <c>TLSCertPairOpt</c> in opts.go.
/// </summary>
public class TlsCertPairOpt
{
public string CertFile { get; set; } = string.Empty;
public string KeyFile { get; set; } = string.Empty;
}
/// <summary>
/// OCSP stapling configuration.
/// Mirrors <c>OCSPConfig</c> in opts.go.
/// </summary>
public class OcspConfig
{
public OcspMode Mode { get; set; }
public List<string> OverrideUrls { get; set; } = [];
}
/// <summary>
/// OCSP response cache configuration.
/// Mirrors <c>OCSPResponseCacheConfig</c> in opts.go.
/// </summary>
public class OcspResponseCacheConfig
{
public string Type { get; set; } = string.Empty;
public string LocalStore { get; set; } = string.Empty;
public bool PreserveRevoked { get; set; }
public double SaveInterval { get; set; }
}
/// <summary>
/// Cluster configuration options.
/// Mirrors <c>ClusterOpts</c> in opts.go.
/// </summary>
public class ClusterOpts
{
public string Name { get; set; } = string.Empty;
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public double AuthTimeout { get; set; }
public double TlsTimeout { get; set; }
public SslServerAuthenticationOptions? TlsConfig { get; set; }
public bool TlsMap { get; set; }
public bool TlsCheckKnownUrls { get; set; }
public PinnedCertSet? TlsPinnedCerts { get; set; }
public bool TlsHandshakeFirst { get; set; }
public TimeSpan TlsHandshakeFirstFallback { get; set; }
public string ListenStr { get; set; } = string.Empty;
public string Advertise { get; set; } = string.Empty;
public bool NoAdvertise { get; set; }
public int ConnectRetries { get; set; }
public bool ConnectBackoff { get; set; }
public int PoolSize { get; set; }
public List<string> PinnedAccounts { get; set; } = [];
public CompressionOpts Compression { get; set; } = new();
public TimeSpan PingInterval { get; set; }
public int MaxPingsOut { get; set; }
public TimeSpan WriteDeadline { get; set; }
public WriteTimeoutPolicy WriteTimeout { get; set; }
internal TlsConfigOpts? TlsConfigOpts { get; set; }
}
/// <summary>
/// Gateway configuration options.
/// Mirrors <c>GatewayOpts</c> in opts.go.
/// </summary>
public class GatewayOpts
{
public string Name { get; set; } = string.Empty;
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public double AuthTimeout { get; set; }
public SslServerAuthenticationOptions? TlsConfig { get; set; }
public double TlsTimeout { get; set; }
public bool TlsMap { get; set; }
public bool TlsCheckKnownUrls { get; set; }
public PinnedCertSet? TlsPinnedCerts { get; set; }
public string Advertise { get; set; } = string.Empty;
public int ConnectRetries { get; set; }
public bool ConnectBackoff { get; set; }
public List<RemoteGatewayOpts> Gateways { get; set; } = [];
public bool RejectUnknown { get; set; }
public TimeSpan WriteDeadline { get; set; }
public WriteTimeoutPolicy WriteTimeout { get; set; }
internal TlsConfigOpts? TlsConfigOpts { get; set; }
}
/// <summary>
/// Remote gateway connection options.
/// Mirrors <c>RemoteGatewayOpts</c> in opts.go.
/// </summary>
public class RemoteGatewayOpts
{
public string Name { get; set; } = string.Empty;
public SslServerAuthenticationOptions? TlsConfig { get; set; }
public double TlsTimeout { get; set; }
public List<Uri> Urls { get; set; } = [];
internal TlsConfigOpts? TlsConfigOpts { get; set; }
}
/// <summary>
/// Leaf node configuration options.
/// Mirrors <c>LeafNodeOpts</c> in opts.go.
/// </summary>
public class LeafNodeOpts
{
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public bool ProxyRequired { get; set; }
public string Nkey { get; set; } = string.Empty;
public string Account { get; set; } = string.Empty;
public double AuthTimeout { get; set; }
public SslServerAuthenticationOptions? TlsConfig { get; set; }
public double TlsTimeout { get; set; }
public bool TlsMap { get; set; }
public PinnedCertSet? TlsPinnedCerts { get; set; }
public bool TlsHandshakeFirst { get; set; }
public TimeSpan TlsHandshakeFirstFallback { get; set; }
public string Advertise { get; set; } = string.Empty;
public bool NoAdvertise { get; set; }
public TimeSpan ReconnectInterval { get; set; }
public TimeSpan WriteDeadline { get; set; }
public WriteTimeoutPolicy WriteTimeout { get; set; }
public CompressionOpts Compression { get; set; } = new();
public List<RemoteLeafOpts> Remotes { get; set; } = [];
public string MinVersion { get; set; } = string.Empty;
public bool IsolateLeafnodeInterest { get; set; }
internal TlsConfigOpts? TlsConfigOpts { get; set; }
}
/// <summary>
/// Signature handler delegate for NKey authentication.
/// Mirrors <c>SignatureHandler</c> in opts.go.
/// </summary>
public delegate (string jwt, byte[] signature, Exception? err) SignatureHandler(byte[] nonce);
/// <summary>
/// Options for connecting to a remote leaf node.
/// Mirrors <c>RemoteLeafOpts</c> in opts.go.
/// </summary>
public class RemoteLeafOpts
{
public string LocalAccount { get; set; } = string.Empty;
public bool NoRandomize { get; set; }
public List<Uri> Urls { get; set; } = [];
public string Credentials { get; set; } = string.Empty;
public string Nkey { get; set; } = string.Empty;
public SignatureHandler? SignatureCb { get; set; }
public bool Tls { get; set; }
public SslServerAuthenticationOptions? TlsConfig { get; set; }
public double TlsTimeout { get; set; }
public bool TlsHandshakeFirst { get; set; }
public bool Hub { get; set; }
public List<string> DenyImports { get; set; } = [];
public List<string> DenyExports { get; set; } = [];
public TimeSpan FirstInfoTimeout { get; set; }
public CompressionOpts Compression { get; set; } = new();
public RemoteLeafWebsocketOpts Websocket { get; set; } = new();
public RemoteLeafProxyOpts Proxy { get; set; } = new();
public bool JetStreamClusterMigrate { get; set; }
public TimeSpan JetStreamClusterMigrateDelay { get; set; }
public bool LocalIsolation { get; set; }
public bool RequestIsolation { get; set; }
public bool Disabled { get; set; }
internal TlsConfigOpts? TlsConfigOpts { get; set; }
}
/// <summary>WebSocket sub-options for a remote leaf connection.</summary>
public class RemoteLeafWebsocketOpts
{
public bool Compression { get; set; }
public bool NoMasking { get; set; }
}
/// <summary>HTTP proxy sub-options for a remote leaf connection.</summary>
public class RemoteLeafProxyOpts
{
public string Url { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public TimeSpan Timeout { get; set; }
}
/// <summary>
/// WebSocket configuration options.
/// Mirrors <c>WebsocketOpts</c> in opts.go.
/// </summary>
public class WebsocketOpts
{
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
public string Advertise { get; set; } = string.Empty;
public string NoAuthUser { get; set; } = string.Empty;
public string JwtCookie { get; set; } = string.Empty;
public string UsernameCookie { get; set; } = string.Empty;
public string PasswordCookie { get; set; } = string.Empty;
public string TokenCookie { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty;
public double AuthTimeout { get; set; }
public bool NoTls { get; set; }
public SslServerAuthenticationOptions? TlsConfig { get; set; }
public bool TlsMap { get; set; }
public PinnedCertSet? TlsPinnedCerts { get; set; }
public bool SameOrigin { get; set; }
public List<string> AllowedOrigins { get; set; } = [];
public bool Compression { get; set; }
public TimeSpan HandshakeTimeout { get; set; }
public TimeSpan PingInterval { get; set; }
public Dictionary<string, string> Headers { get; set; } = new();
internal TlsConfigOpts? TlsConfigOpts { get; set; }
}
/// <summary>
/// MQTT configuration options.
/// Mirrors <c>MQTTOpts</c> in opts.go.
/// </summary>
public class MqttOpts
{
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
public string NoAuthUser { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty;
public string JsDomain { get; set; } = string.Empty;
public int StreamReplicas { get; set; }
public int ConsumerReplicas { get; set; }
public bool ConsumerMemoryStorage { get; set; }
public TimeSpan ConsumerInactiveThreshold { get; set; }
public double AuthTimeout { get; set; }
public SslServerAuthenticationOptions? TlsConfig { get; set; }
public bool TlsMap { get; set; }
public double TlsTimeout { get; set; }
public PinnedCertSet? TlsPinnedCerts { get; set; }
public TimeSpan AckWait { get; set; }
public TimeSpan JsApiTimeout { get; set; }
public ushort MaxAckPending { get; set; }
internal TlsConfigOpts? TlsConfigOpts { get; set; }
internal bool RejectQoS2Pub { get; set; }
internal bool DowngradeQoS2Sub { get; set; }
}
/// <summary>
/// JetStream server-level limits.
/// Mirrors <c>JSLimitOpts</c> in opts.go.
/// </summary>
public class JsLimitOpts
{
public int MaxRequestBatch { get; set; }
public int MaxAckPending { get; set; }
public int MaxHaAssets { get; set; }
public TimeSpan Duplicates { get; set; }
public int MaxBatchInflightPerStream { get; set; }
public int MaxBatchInflightTotal { get; set; }
public int MaxBatchSize { get; set; }
public TimeSpan MaxBatchTimeout { get; set; }
}
/// <summary>
/// TPM configuration for JetStream encryption.
/// Mirrors <c>JSTpmOpts</c> in opts.go.
/// </summary>
public class JsTpmOpts
{
public string KeysFile { get; set; } = string.Empty;
public string KeyPassword { get; set; } = string.Empty;
public string SrkPassword { get; set; } = string.Empty;
public int Pcr { get; set; }
}
/// <summary>
/// Auth callout options for external authentication.
/// Mirrors <c>AuthCallout</c> in opts.go.
/// </summary>
public class AuthCalloutOpts
{
public string Issuer { get; set; } = string.Empty;
public string Account { get; set; } = string.Empty;
public List<string> AuthUsers { get; set; } = [];
public string XKey { get; set; } = string.Empty;
public List<string> AllowedAccounts { get; set; } = [];
}
/// <summary>
/// Proxy configuration for trusted proxies.
/// Mirrors <c>ProxiesConfig</c> in opts.go.
/// </summary>
public class ProxiesConfig
{
public List<ProxyConfig> Trusted { get; set; } = [];
}
/// <summary>
/// A single trusted proxy identified by public key.
/// Mirrors <c>ProxyConfig</c> in opts.go.
/// </summary>
public class ProxyConfig
{
public string Key { get; set; } = string.Empty;
}
/// <summary>
/// Parsed authorization section from config file.
/// Mirrors the unexported <c>authorization</c> struct in opts.go.
/// </summary>
internal class AuthorizationConfig
{
public string User { get; set; } = string.Empty;
public string Pass { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty;
public string Nkey { get; set; } = string.Empty;
public string Acc { get; set; } = string.Empty;
public bool ProxyRequired { get; set; }
public double Timeout { get; set; }
public AuthCalloutOpts? Callout { get; set; }
}
/// <summary>
/// Custom authentication interface.
/// Mirrors the <c>Authentication</c> interface in auth.go.
/// </summary>
public interface IAuthentication
{
bool Check(IClientAuthentication client);
}
/// <summary>
/// Client-side of authentication check.
/// Mirrors <c>ClientAuthentication</c> in auth.go.
/// </summary>
public interface IClientAuthentication
{
string? GetOpts();
bool IsTls();
string? GetTlsConnectionState();
string RemoteAddress();
}
/// <summary>
/// Account resolver interface for dynamic account loading.
/// Mirrors <c>AccountResolver</c> in accounts.go.
/// </summary>
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();
}

View File

@@ -0,0 +1,569 @@
// 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/opts.go in the NATS server Go source.
using System.Runtime.InteropServices;
using System.Threading;
namespace ZB.MOM.NatsNet.Server;
// Global flag for unknown field handling.
internal static class ConfigFlags
{
private static int _allowUnknownTopLevelField;
/// <summary>
/// Sets whether unknown top-level config fields should be allowed.
/// Mirrors <c>NoErrOnUnknownFields</c> in opts.go.
/// </summary>
public static void NoErrOnUnknownFields(bool noError) =>
Interlocked.Exchange(ref _allowUnknownTopLevelField, noError ? 1 : 0);
public static bool AllowUnknownTopLevelField =>
Interlocked.CompareExchange(ref _allowUnknownTopLevelField, 0, 0) != 0;
}
public sealed partial class ServerOptions
{
/// <summary>
/// Snapshot of command-line flags, populated during <see cref="ConfigureOptions"/>.
/// Mirrors <c>FlagSnapshot</c> in opts.go.
/// </summary>
public static ServerOptions? FlagSnapshot { get; internal set; }
/// <summary>
/// Deep-copies this <see cref="ServerOptions"/> instance.
/// Mirrors <c>Options.Clone()</c> in opts.go.
/// </summary>
public ServerOptions Clone()
{
// Start with a shallow memberwise clone.
var clone = (ServerOptions)MemberwiseClone();
// Deep-copy reference types that need isolation.
if (Routes.Count > 0)
clone.Routes = Routes.Select(u => new Uri(u.ToString())).ToList();
clone.Cluster = CloneClusterOpts(Cluster);
clone.Gateway = CloneGatewayOpts(Gateway);
clone.LeafNode = CloneLeafNodeOpts(LeafNode);
clone.Websocket = CloneWebsocketOpts(Websocket);
clone.Mqtt = CloneMqttOpts(Mqtt);
clone.Tags = [.. Tags];
clone.Metadata = new Dictionary<string, string>(Metadata);
clone.TrustedKeys = [.. TrustedKeys];
clone.JsAccDefaultDomain = new Dictionary<string, string>(JsAccDefaultDomain);
clone.InConfig = new Dictionary<string, bool>(InConfig);
clone.InCmdLine = new Dictionary<string, bool>(InCmdLine);
clone.OperatorJwt = [.. OperatorJwt];
clone.ResolverPreloads = new Dictionary<string, string>(ResolverPreloads);
clone.ResolverPinnedAccounts = [.. ResolverPinnedAccounts];
return clone;
}
/// <summary>
/// Returns the SHA-256 digest of the configuration.
/// Mirrors <c>Options.ConfigDigest()</c> in opts.go.
/// </summary>
public string ConfigDigest() => ConfigDigestValue;
// -------------------------------------------------------------------------
// Merge / Baseline
// -------------------------------------------------------------------------
/// <summary>
/// Merges file-based options with command-line flag options.
/// Flag options override file options where set.
/// Mirrors <c>MergeOptions</c> in opts.go.
/// </summary>
public static ServerOptions MergeOptions(ServerOptions? fileOpts, ServerOptions? flagOpts)
{
if (fileOpts == null) return flagOpts ?? new ServerOptions();
if (flagOpts == null) return fileOpts;
var opts = fileOpts.Clone();
if (flagOpts.Port != 0) opts.Port = flagOpts.Port;
if (!string.IsNullOrEmpty(flagOpts.Host)) opts.Host = flagOpts.Host;
if (flagOpts.DontListen) opts.DontListen = true;
if (!string.IsNullOrEmpty(flagOpts.ClientAdvertise)) opts.ClientAdvertise = flagOpts.ClientAdvertise;
if (!string.IsNullOrEmpty(flagOpts.Username)) opts.Username = flagOpts.Username;
if (!string.IsNullOrEmpty(flagOpts.Password)) opts.Password = flagOpts.Password;
if (!string.IsNullOrEmpty(flagOpts.Authorization)) opts.Authorization = flagOpts.Authorization;
if (flagOpts.HttpPort != 0) opts.HttpPort = flagOpts.HttpPort;
if (!string.IsNullOrEmpty(flagOpts.HttpBasePath)) opts.HttpBasePath = flagOpts.HttpBasePath;
if (flagOpts.Debug) opts.Debug = true;
if (flagOpts.Trace) opts.Trace = true;
if (flagOpts.Logtime) opts.Logtime = true;
if (!string.IsNullOrEmpty(flagOpts.LogFile)) opts.LogFile = flagOpts.LogFile;
if (!string.IsNullOrEmpty(flagOpts.PidFile)) opts.PidFile = flagOpts.PidFile;
if (!string.IsNullOrEmpty(flagOpts.PortsFileDir)) opts.PortsFileDir = flagOpts.PortsFileDir;
if (flagOpts.ProfPort != 0) opts.ProfPort = flagOpts.ProfPort;
if (!string.IsNullOrEmpty(flagOpts.Cluster.ListenStr)) opts.Cluster.ListenStr = flagOpts.Cluster.ListenStr;
if (flagOpts.Cluster.NoAdvertise) opts.Cluster.NoAdvertise = true;
if (flagOpts.Cluster.ConnectRetries != 0) opts.Cluster.ConnectRetries = flagOpts.Cluster.ConnectRetries;
if (!string.IsNullOrEmpty(flagOpts.Cluster.Advertise)) opts.Cluster.Advertise = flagOpts.Cluster.Advertise;
if (!string.IsNullOrEmpty(flagOpts.RoutesStr)) MergeRoutes(opts, flagOpts);
if (flagOpts.JetStream) opts.JetStream = true;
if (!string.IsNullOrEmpty(flagOpts.StoreDir)) opts.StoreDir = flagOpts.StoreDir;
return opts;
}
/// <summary>
/// Parses route URLs from a comma-separated string.
/// Mirrors <c>RoutesFromStr</c> in opts.go.
/// </summary>
public static List<Uri> RoutesFromStr(string routesStr)
{
var parts = routesStr.Split(',');
if (parts.Length == 0) return [];
var urls = new List<Uri>();
foreach (var r in parts)
{
var trimmed = r.Trim();
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var u))
urls.Add(u);
}
return urls;
}
/// <summary>
/// Applies system-wide defaults to any unset options.
/// Mirrors <c>setBaselineOptions</c> in opts.go.
/// </summary>
public void SetBaselineOptions()
{
if (string.IsNullOrEmpty(Host))
Host = ServerConstants.DefaultHost;
if (string.IsNullOrEmpty(HttpHost))
HttpHost = Host;
if (Port == 0)
Port = ServerConstants.DefaultPort;
else if (Port == ServerConstants.RandomPort)
Port = 0;
if (MaxConn == 0)
MaxConn = ServerConstants.DefaultMaxConnections;
if (PingInterval == TimeSpan.Zero)
PingInterval = ServerConstants.DefaultPingInterval;
if (MaxPingsOut == 0)
MaxPingsOut = ServerConstants.DefaultPingMaxOut;
if (TlsTimeout == 0)
TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
if (AuthTimeout == 0)
AuthTimeout = GetDefaultAuthTimeout(TlsConfig, TlsTimeout);
// Cluster defaults
if (Cluster.Port != 0 || !string.IsNullOrEmpty(Cluster.ListenStr))
{
if (string.IsNullOrEmpty(Cluster.Host))
Cluster.Host = ServerConstants.DefaultHost;
if (Cluster.TlsTimeout == 0)
Cluster.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
if (Cluster.AuthTimeout == 0)
Cluster.AuthTimeout = GetDefaultAuthTimeout(Cluster.TlsConfig, Cluster.TlsTimeout);
if (Cluster.PoolSize == 0)
Cluster.PoolSize = ServerConstants.DefaultRoutePoolSize;
// Add system account to pinned accounts if pool is enabled.
if (Cluster.PoolSize > 0)
{
var sysAccName = SystemAccount;
if (string.IsNullOrEmpty(sysAccName) && !NoSystemAccount)
sysAccName = ServerConstants.DefaultSystemAccount;
if (!string.IsNullOrEmpty(sysAccName) && !Cluster.PinnedAccounts.Contains(sysAccName))
Cluster.PinnedAccounts.Add(sysAccName);
}
// Default compression to "accept".
if (string.IsNullOrEmpty(Cluster.Compression.Mode))
Cluster.Compression.Mode = CompressionModes.Accept;
}
// LeafNode defaults
if (LeafNode.Port != 0)
{
if (string.IsNullOrEmpty(LeafNode.Host))
LeafNode.Host = ServerConstants.DefaultHost;
if (LeafNode.TlsTimeout == 0)
LeafNode.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
if (LeafNode.AuthTimeout == 0)
LeafNode.AuthTimeout = GetDefaultAuthTimeout(LeafNode.TlsConfig, LeafNode.TlsTimeout);
if (string.IsNullOrEmpty(LeafNode.Compression.Mode))
LeafNode.Compression.Mode = CompressionModes.S2Auto;
}
// Remote leafnode defaults
foreach (var r in LeafNode.Remotes)
{
foreach (var u in r.Urls)
{
if (u.IsDefaultPort || string.IsNullOrEmpty(u.GetComponents(UriComponents.Port, UriFormat.Unescaped)))
{
var builder = new UriBuilder(u) { Port = ServerConstants.DefaultLeafNodePort };
r.Urls[r.Urls.IndexOf(u)] = builder.Uri;
}
}
if (string.IsNullOrEmpty(r.Compression.Mode))
r.Compression.Mode = CompressionModes.S2Auto;
if (r.FirstInfoTimeout <= TimeSpan.Zero)
r.FirstInfoTimeout = ServerConstants.DefaultLeafNodeInfoWait;
}
if (LeafNode.ReconnectInterval == TimeSpan.Zero)
LeafNode.ReconnectInterval = ServerConstants.DefaultLeafNodeReconnect;
// Protocol limits
if (MaxControlLine == 0)
MaxControlLine = ServerConstants.MaxControlLineSize;
if (MaxPayload == 0)
MaxPayload = ServerConstants.MaxPayloadSize;
if (MaxPending == 0)
MaxPending = ServerConstants.MaxPendingSize;
if (WriteDeadline == TimeSpan.Zero)
WriteDeadline = ServerConstants.DefaultFlushDeadline;
if (MaxClosedClients == 0)
MaxClosedClients = ServerConstants.DefaultMaxClosedClients;
if (LameDuckDuration == TimeSpan.Zero)
LameDuckDuration = ServerConstants.DefaultLameDuckDuration;
if (LameDuckGracePeriod == TimeSpan.Zero)
LameDuckGracePeriod = ServerConstants.DefaultLameDuckGracePeriod;
// Gateway defaults
if (Gateway.Port != 0)
{
if (string.IsNullOrEmpty(Gateway.Host))
Gateway.Host = ServerConstants.DefaultHost;
if (Gateway.TlsTimeout == 0)
Gateway.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
if (Gateway.AuthTimeout == 0)
Gateway.AuthTimeout = GetDefaultAuthTimeout(Gateway.TlsConfig, Gateway.TlsTimeout);
}
// Error reporting
if (ConnectErrorReports == 0)
ConnectErrorReports = ServerConstants.DefaultConnectErrorReports;
if (ReconnectErrorReports == 0)
ReconnectErrorReports = ServerConstants.DefaultReconnectErrorReports;
// WebSocket defaults
if (Websocket.Port != 0)
{
if (string.IsNullOrEmpty(Websocket.Host))
Websocket.Host = ServerConstants.DefaultHost;
}
// MQTT defaults
if (Mqtt.Port != 0)
{
if (string.IsNullOrEmpty(Mqtt.Host))
Mqtt.Host = ServerConstants.DefaultHost;
if (Mqtt.TlsTimeout == 0)
Mqtt.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
}
// JetStream defaults
if (JetStreamMaxMemory == 0 && !MaxMemSet)
JetStreamMaxMemory = -1;
if (JetStreamMaxStore == 0 && !MaxStoreSet)
JetStreamMaxStore = -1;
if (SyncInterval == TimeSpan.Zero && !SyncSet)
SyncInterval = TimeSpan.FromMinutes(2); // defaultSyncInterval
if (JetStreamRequestQueueLimit <= 0)
JetStreamRequestQueueLimit = 4096; // JSDefaultRequestQueueLimit
}
/// <summary>
/// Normalizes an HTTP base path (ensure leading slash, clean redundant separators).
/// Mirrors <c>normalizeBasePath</c> in opts.go.
/// </summary>
public static string NormalizeBasePath(string p)
{
if (string.IsNullOrEmpty(p)) return "/";
if (p[0] != '/') p = "/" + p;
// Simple path clean: collapse repeated slashes and remove trailing slash.
while (p.Contains("//")) p = p.Replace("//", "/");
return p.Length > 1 && p.EndsWith('/') ? p[..^1] : p;
}
/// <summary>
/// Computes the default auth timeout based on TLS config presence.
/// Mirrors <c>getDefaultAuthTimeout</c> in opts.go.
/// </summary>
public static double GetDefaultAuthTimeout(object? tlsConfig, double tlsTimeout)
{
if (tlsConfig != null)
return tlsTimeout + 1.0;
return ServerConstants.AuthTimeout.TotalSeconds;
}
/// <summary>
/// Returns the user's home directory.
/// Mirrors <c>homeDir</c> in opts.go.
/// </summary>
public static string HomeDir() => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
/// <summary>
/// Expands environment variables and ~/ prefix in a path.
/// Mirrors <c>expandPath</c> in opts.go.
/// </summary>
public static string ExpandPath(string p)
{
p = Environment.ExpandEnvironmentVariables(p);
if (!p.StartsWith('~')) return p;
return Path.Combine(HomeDir(), p[1..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
}
/// <summary>
/// Reads a PID from a file path if possible, otherwise returns the string as-is.
/// Mirrors <c>maybeReadPidFile</c> in opts.go.
/// </summary>
public static string MaybeReadPidFile(string pidStr)
{
try { return File.ReadAllText(pidStr).Trim(); }
catch { return pidStr; }
}
/// <summary>
/// Applies TLS overrides from command-line options.
/// Mirrors <c>overrideTLS</c> in opts.go.
/// </summary>
public Exception? OverrideTls()
{
if (string.IsNullOrEmpty(TlsCert))
return new InvalidOperationException("TLS Server certificate must be present and valid");
if (string.IsNullOrEmpty(TlsKey))
return new InvalidOperationException("TLS Server private key must be present and valid");
// TLS config generation is deferred to GenTlsConfig (session 06+).
// For now, mark that TLS is enabled.
Tls = true;
return null;
}
/// <summary>
/// Overrides cluster options from the --cluster flag.
/// Mirrors <c>overrideCluster</c> in opts.go.
/// </summary>
public Exception? OverrideCluster()
{
if (string.IsNullOrEmpty(Cluster.ListenStr))
{
Cluster.Port = 0;
return null;
}
var listenStr = Cluster.ListenStr;
var wantsRandom = false;
if (listenStr.EndsWith(":-1"))
{
wantsRandom = true;
listenStr = listenStr[..^3] + ":0";
}
if (!Uri.TryCreate(listenStr, UriKind.Absolute, out var clusterUri))
return new InvalidOperationException($"could not parse cluster URL: {Cluster.ListenStr}");
Cluster.Host = clusterUri.Host;
Cluster.Port = wantsRandom ? -1 : clusterUri.Port;
var userInfo = clusterUri.UserInfo;
if (!string.IsNullOrEmpty(userInfo))
{
var parts = userInfo.Split(':', 2);
if (parts.Length != 2)
return new InvalidOperationException("expected cluster password to be set");
Cluster.Username = parts[0];
Cluster.Password = parts[1];
}
else
{
Cluster.Username = string.Empty;
Cluster.Password = string.Empty;
}
return null;
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static void MergeRoutes(ServerOptions opts, ServerOptions flagOpts)
{
var routeUrls = RoutesFromStr(flagOpts.RoutesStr);
if (routeUrls.Count == 0) return;
opts.Routes = routeUrls;
opts.RoutesStr = flagOpts.RoutesStr;
}
internal static void TrackExplicitVal(Dictionary<string, bool> pm, string name, bool val) =>
pm[name] = val;
private static ClusterOpts CloneClusterOpts(ClusterOpts src) => new()
{
Name = src.Name,
Host = src.Host,
Port = src.Port,
Username = src.Username,
Password = src.Password,
AuthTimeout = src.AuthTimeout,
TlsTimeout = src.TlsTimeout,
TlsConfig = src.TlsConfig,
TlsMap = src.TlsMap,
TlsCheckKnownUrls = src.TlsCheckKnownUrls,
TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null,
TlsHandshakeFirst = src.TlsHandshakeFirst,
TlsHandshakeFirstFallback = src.TlsHandshakeFirstFallback,
ListenStr = src.ListenStr,
Advertise = src.Advertise,
NoAdvertise = src.NoAdvertise,
ConnectRetries = src.ConnectRetries,
ConnectBackoff = src.ConnectBackoff,
PoolSize = src.PoolSize,
PinnedAccounts = [.. src.PinnedAccounts],
Compression = new CompressionOpts { Mode = src.Compression.Mode, RttThresholds = [.. src.Compression.RttThresholds] },
PingInterval = src.PingInterval,
MaxPingsOut = src.MaxPingsOut,
WriteDeadline = src.WriteDeadline,
WriteTimeout = src.WriteTimeout,
};
private static GatewayOpts CloneGatewayOpts(GatewayOpts src) => new()
{
Name = src.Name,
Host = src.Host,
Port = src.Port,
Username = src.Username,
Password = src.Password,
AuthTimeout = src.AuthTimeout,
TlsConfig = src.TlsConfig,
TlsTimeout = src.TlsTimeout,
TlsMap = src.TlsMap,
TlsCheckKnownUrls = src.TlsCheckKnownUrls,
TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null,
Advertise = src.Advertise,
ConnectRetries = src.ConnectRetries,
ConnectBackoff = src.ConnectBackoff,
Gateways = src.Gateways.Select(g => new RemoteGatewayOpts
{
Name = g.Name,
TlsConfig = g.TlsConfig,
TlsTimeout = g.TlsTimeout,
Urls = g.Urls.Select(u => new Uri(u.ToString())).ToList(),
}).ToList(),
RejectUnknown = src.RejectUnknown,
WriteDeadline = src.WriteDeadline,
WriteTimeout = src.WriteTimeout,
};
private static LeafNodeOpts CloneLeafNodeOpts(LeafNodeOpts src) => new()
{
Host = src.Host,
Port = src.Port,
Username = src.Username,
Password = src.Password,
ProxyRequired = src.ProxyRequired,
Nkey = src.Nkey,
Account = src.Account,
AuthTimeout = src.AuthTimeout,
TlsConfig = src.TlsConfig,
TlsTimeout = src.TlsTimeout,
TlsMap = src.TlsMap,
TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null,
TlsHandshakeFirst = src.TlsHandshakeFirst,
TlsHandshakeFirstFallback = src.TlsHandshakeFirstFallback,
Advertise = src.Advertise,
NoAdvertise = src.NoAdvertise,
ReconnectInterval = src.ReconnectInterval,
WriteDeadline = src.WriteDeadline,
WriteTimeout = src.WriteTimeout,
Compression = new CompressionOpts { Mode = src.Compression.Mode, RttThresholds = [.. src.Compression.RttThresholds] },
Remotes = src.Remotes.Select(r => new RemoteLeafOpts
{
LocalAccount = r.LocalAccount,
NoRandomize = r.NoRandomize,
Urls = r.Urls.Select(u => new Uri(u.ToString())).ToList(),
Credentials = r.Credentials,
Nkey = r.Nkey,
Tls = r.Tls,
TlsTimeout = r.TlsTimeout,
TlsHandshakeFirst = r.TlsHandshakeFirst,
Hub = r.Hub,
DenyImports = [.. r.DenyImports],
DenyExports = [.. r.DenyExports],
FirstInfoTimeout = r.FirstInfoTimeout,
Compression = new CompressionOpts { Mode = r.Compression.Mode, RttThresholds = [.. r.Compression.RttThresholds] },
JetStreamClusterMigrate = r.JetStreamClusterMigrate,
JetStreamClusterMigrateDelay = r.JetStreamClusterMigrateDelay,
LocalIsolation = r.LocalIsolation,
RequestIsolation = r.RequestIsolation,
Disabled = r.Disabled,
}).ToList(),
MinVersion = src.MinVersion,
IsolateLeafnodeInterest = src.IsolateLeafnodeInterest,
};
private static WebsocketOpts CloneWebsocketOpts(WebsocketOpts src) => new()
{
Host = src.Host,
Port = src.Port,
Advertise = src.Advertise,
NoAuthUser = src.NoAuthUser,
JwtCookie = src.JwtCookie,
UsernameCookie = src.UsernameCookie,
PasswordCookie = src.PasswordCookie,
TokenCookie = src.TokenCookie,
Username = src.Username,
Password = src.Password,
Token = src.Token,
AuthTimeout = src.AuthTimeout,
NoTls = src.NoTls,
TlsConfig = src.TlsConfig,
TlsMap = src.TlsMap,
TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null,
SameOrigin = src.SameOrigin,
AllowedOrigins = [.. src.AllowedOrigins],
Compression = src.Compression,
HandshakeTimeout = src.HandshakeTimeout,
PingInterval = src.PingInterval,
Headers = new Dictionary<string, string>(src.Headers),
};
private static MqttOpts CloneMqttOpts(MqttOpts src) => new()
{
Host = src.Host,
Port = src.Port,
NoAuthUser = src.NoAuthUser,
Username = src.Username,
Password = src.Password,
Token = src.Token,
JsDomain = src.JsDomain,
StreamReplicas = src.StreamReplicas,
ConsumerReplicas = src.ConsumerReplicas,
ConsumerMemoryStorage = src.ConsumerMemoryStorage,
ConsumerInactiveThreshold = src.ConsumerInactiveThreshold,
AuthTimeout = src.AuthTimeout,
TlsConfig = src.TlsConfig,
TlsMap = src.TlsMap,
TlsTimeout = src.TlsTimeout,
TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null,
AckWait = src.AckWait,
JsApiTimeout = src.JsApiTimeout,
MaxAckPending = src.MaxAckPending,
};
}

View File

@@ -0,0 +1,238 @@
// 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/opts.go in the NATS server Go source.
using System.Net.Security;
using System.Security.Authentication;
using System.Threading;
using ZB.MOM.NatsNet.Server.Auth;
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// Server configuration options block.
/// Mirrors <c>Options</c> struct in opts.go.
/// </summary>
public sealed partial class ServerOptions
{
// -------------------------------------------------------------------------
// General / Startup
// -------------------------------------------------------------------------
public string ConfigFile { get; set; } = string.Empty;
public string ServerName { get; set; } = string.Empty;
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
public bool DontListen { get; set; }
public string ClientAdvertise { get; set; } = string.Empty;
public bool CheckConfig { get; set; }
public string PidFile { get; set; } = string.Empty;
public string PortsFileDir { get; set; } = string.Empty;
// -------------------------------------------------------------------------
// Logging & Debugging
// -------------------------------------------------------------------------
public bool Trace { get; set; }
public bool Debug { get; set; }
public bool TraceVerbose { get; set; }
public bool TraceHeaders { get; set; }
public bool NoLog { get; set; }
public bool NoSigs { get; set; }
public bool Logtime { get; set; }
public bool LogtimeUtc { get; set; }
public string LogFile { get; set; } = string.Empty;
public long LogSizeLimit { get; set; }
public long LogMaxFiles { get; set; }
public bool Syslog { get; set; }
public string RemoteSyslog { get; set; } = string.Empty;
public int ProfPort { get; set; }
public int ProfBlockRate { get; set; }
public int MaxTracedMsgLen { get; set; }
// -------------------------------------------------------------------------
// Networking & Limits
// -------------------------------------------------------------------------
public int MaxConn { get; set; }
public int MaxSubs { get; set; }
public byte MaxSubTokens { get; set; }
public int MaxControlLine { get; set; }
public int MaxPayload { get; set; }
public long MaxPending { get; set; }
public bool NoFastProducerStall { get; set; }
public bool ProxyRequired { get; set; }
public bool ProxyProtocol { get; set; }
public int MaxClosedClients { get; set; }
// -------------------------------------------------------------------------
// Connectivity
// -------------------------------------------------------------------------
public TimeSpan PingInterval { get; set; }
public int MaxPingsOut { get; set; }
public TimeSpan WriteDeadline { get; set; }
public WriteTimeoutPolicy WriteTimeout { get; set; }
public TimeSpan LameDuckDuration { get; set; }
public TimeSpan LameDuckGracePeriod { get; set; }
// -------------------------------------------------------------------------
// HTTP / Monitoring
// -------------------------------------------------------------------------
public string HttpHost { get; set; } = string.Empty;
public int HttpPort { get; set; }
public string HttpBasePath { get; set; } = string.Empty;
public int HttpsPort { get; set; }
// -------------------------------------------------------------------------
// Authentication & Authorization
// -------------------------------------------------------------------------
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string Authorization { get; set; } = string.Empty;
public double AuthTimeout { get; set; }
public string NoAuthUser { get; set; } = string.Empty;
public string DefaultSentinel { get; set; } = string.Empty;
public string SystemAccount { get; set; } = string.Empty;
public bool NoSystemAccount { get; set; }
public AuthCalloutOpts? AuthCallout { get; set; }
public bool AlwaysEnableNonce { get; set; }
public List<User>? Users { get; set; }
public List<NkeyUser>? Nkeys { get; set; }
public List<object> TrustedOperators { get; set; } = [];
public IAuthentication? CustomClientAuthentication { get; set; }
public IAuthentication? CustomRouterAuthentication { get; set; }
// -------------------------------------------------------------------------
// Sublist
// -------------------------------------------------------------------------
public bool NoSublistCache { get; set; }
public bool NoHeaderSupport { get; set; }
public bool DisableShortFirstPing { get; set; }
// -------------------------------------------------------------------------
// TLS (Client)
// -------------------------------------------------------------------------
public double TlsTimeout { get; set; }
public bool Tls { get; set; }
public bool TlsVerify { get; set; }
public bool TlsMap { get; set; }
public string TlsCert { get; set; } = string.Empty;
public string TlsKey { get; set; } = string.Empty;
public string TlsCaCert { get; set; } = string.Empty;
public SslServerAuthenticationOptions? TlsConfig { get; set; }
public PinnedCertSet? TlsPinnedCerts { get; set; }
public long TlsRateLimit { get; set; }
public bool TlsHandshakeFirst { get; set; }
public TimeSpan TlsHandshakeFirstFallback { get; set; }
public bool AllowNonTls { get; set; }
// -------------------------------------------------------------------------
// Cluster / Gateway / Leaf / WebSocket / MQTT
// -------------------------------------------------------------------------
public ClusterOpts Cluster { get; set; } = new();
public GatewayOpts Gateway { get; set; } = new();
public LeafNodeOpts LeafNode { get; set; } = new();
public WebsocketOpts Websocket { get; set; } = new();
public MqttOpts Mqtt { get; set; } = new();
// -------------------------------------------------------------------------
// Routing
// -------------------------------------------------------------------------
public List<Uri> Routes { get; set; } = [];
public string RoutesStr { get; set; } = string.Empty;
// -------------------------------------------------------------------------
// JetStream
// -------------------------------------------------------------------------
public bool JetStream { get; set; }
public bool NoJetStreamStrict { get; set; }
public long JetStreamMaxMemory { get; set; }
public long JetStreamMaxStore { get; set; }
public string JetStreamDomain { get; set; } = string.Empty;
public string JetStreamExtHint { get; set; } = string.Empty;
public string JetStreamKey { get; set; } = string.Empty;
public string JetStreamOldKey { get; set; } = string.Empty;
public StoreCipher JetStreamCipher { get; set; }
public string JetStreamUniqueTag { get; set; } = string.Empty;
public JsLimitOpts JetStreamLimits { get; set; } = new();
public JsTpmOpts JetStreamTpm { get; set; } = new();
public long JetStreamMaxCatchup { get; set; }
public long JetStreamRequestQueueLimit { get; set; }
public ulong JetStreamMetaCompact { get; set; }
public ulong JetStreamMetaCompactSize { get; set; }
public bool JetStreamMetaCompactSync { get; set; }
public int StreamMaxBufferedMsgs { get; set; }
public long StreamMaxBufferedSize { get; set; }
public string StoreDir { get; set; } = string.Empty;
public TimeSpan SyncInterval { get; set; }
public bool SyncAlways { get; set; }
public Dictionary<string, string> JsAccDefaultDomain { get; set; } = new();
public bool DisableJetStreamBanner { get; set; }
// -------------------------------------------------------------------------
// Security & Trust
// -------------------------------------------------------------------------
public List<string> TrustedKeys { get; set; } = [];
public SslServerAuthenticationOptions? AccountResolverTlsConfig { get; set; }
public IAccountResolver? AccountResolver { get; set; }
public OcspConfig? OcspConfig { get; set; }
public OcspResponseCacheConfig? OcspCacheConfig { get; set; }
// -------------------------------------------------------------------------
// Tagging & Metadata
// -------------------------------------------------------------------------
public List<string> Tags { get; set; } = [];
public Dictionary<string, string> Metadata { get; set; } = new();
// -------------------------------------------------------------------------
// Proxies
// -------------------------------------------------------------------------
public ProxiesConfig? Proxies { get; set; }
// -------------------------------------------------------------------------
// Connectivity error reporting
// -------------------------------------------------------------------------
public int ConnectErrorReports { get; set; }
public int ReconnectErrorReports { get; set; }
// -------------------------------------------------------------------------
// Internal / Private fields
// -------------------------------------------------------------------------
internal Dictionary<string, bool> InConfig { get; set; } = new();
internal Dictionary<string, bool> InCmdLine { get; set; } = new();
internal List<string> OperatorJwt { get; set; } = [];
internal Dictionary<string, string> ResolverPreloads { get; set; } = new();
internal HashSet<string> ResolverPinnedAccounts { get; set; } = [];
internal TimeSpan GatewaysSolicitDelay { get; set; }
internal int OverrideProto { get; set; }
internal bool MaxMemSet { get; set; }
internal bool MaxStoreSet { get; set; }
internal bool SyncSet { get; set; }
internal bool AuthBlockDefined { get; set; }
internal string ConfigDigestValue { get; set; } = string.Empty;
internal TlsConfigOpts? TlsConfigOpts { get; set; }
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="*" />
<PackageReference Include="Microsoft.Extensions.Options" Version="*" />
<PackageReference Include="BCrypt.Net-Next" Version="*" />
<PackageReference Include="IronSnappy" Version="*" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace ZB.MOM.NatsNet.Server.IntegrationTests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="Shouldly" Version="*" />
<PackageReference Include="NSubstitute" Version="*" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,384 @@
// 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.
using Shouldly;
using ZB.MOM.NatsNet.Server.Auth;
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
/// <summary>
/// Tests for AuthHandler standalone functions.
/// Mirrors Go auth_test.go and adds unit tests for validators.
/// </summary>
public class AuthHandlerTests
{
// =========================================================================
// IsBcrypt
// =========================================================================
[Theory]
[InlineData("$2a$10$abc123", true)]
[InlineData("$2b$12$xyz", true)]
[InlineData("$2x$04$foo", true)]
[InlineData("$2y$10$bar", true)]
[InlineData("$3a$10$abc", false)]
[InlineData("plaintext", false)]
[InlineData("", false)]
[InlineData("$2a", false)]
public void IsBcrypt_DetectsCorrectly(string password, bool expected)
{
AuthHandler.IsBcrypt(password).ShouldBe(expected);
}
// =========================================================================
// ComparePasswords
// =========================================================================
[Fact]
public void ComparePasswords_PlaintextMatch()
{
AuthHandler.ComparePasswords("secret", "secret").ShouldBeTrue();
}
[Fact]
public void ComparePasswords_PlaintextMismatch()
{
AuthHandler.ComparePasswords("secret", "wrong").ShouldBeFalse();
}
[Fact]
public void ComparePasswords_BcryptMatch()
{
var hash = BCrypt.Net.BCrypt.HashPassword("mypassword");
AuthHandler.ComparePasswords(hash, "mypassword").ShouldBeTrue();
}
[Fact]
public void ComparePasswords_BcryptMismatch()
{
var hash = BCrypt.Net.BCrypt.HashPassword("mypassword");
AuthHandler.ComparePasswords(hash, "wrongpassword").ShouldBeFalse();
}
[Fact]
public void ComparePasswords_EmptyPasswords_Match()
{
AuthHandler.ComparePasswords("", "").ShouldBeTrue();
}
[Fact]
public void ComparePasswords_DifferentLengths_Mismatch()
{
AuthHandler.ComparePasswords("short", "longpassword").ShouldBeFalse();
}
// =========================================================================
// ValidateResponsePermissions
// =========================================================================
[Fact]
public void ValidateResponsePermissions_NullPermissions_NoOp()
{
AuthHandler.ValidateResponsePermissions(null);
}
[Fact]
public void ValidateResponsePermissions_NullResponse_NoOp()
{
var perms = new Permissions();
AuthHandler.ValidateResponsePermissions(perms);
perms.Publish.ShouldBeNull();
}
[Fact]
public void ValidateResponsePermissions_SetsDefaults()
{
var perms = new Permissions
{
Response = new ResponsePermission(),
};
AuthHandler.ValidateResponsePermissions(perms);
perms.Response.MaxMsgs.ShouldBe(ServerConstants.DefaultAllowResponseMaxMsgs);
perms.Response.Expires.ShouldBe(ServerConstants.DefaultAllowResponseExpiration);
perms.Publish.ShouldNotBeNull();
perms.Publish!.Allow.ShouldNotBeNull();
perms.Publish.Allow!.Count.ShouldBe(0);
}
[Fact]
public void ValidateResponsePermissions_PreservesExistingValues()
{
var perms = new Permissions
{
Publish = new SubjectPermission { Allow = ["foo.>"] },
Response = new ResponsePermission
{
MaxMsgs = 10,
Expires = TimeSpan.FromMinutes(5),
},
};
AuthHandler.ValidateResponsePermissions(perms);
perms.Response.MaxMsgs.ShouldBe(10);
perms.Response.Expires.ShouldBe(TimeSpan.FromMinutes(5));
perms.Publish.Allow.ShouldBe(["foo.>"]);
}
// =========================================================================
// ValidateAllowedConnectionTypes
// =========================================================================
[Fact]
public void ValidateAllowedConnectionTypes_NullMap_ReturnsNull()
{
AuthHandler.ValidateAllowedConnectionTypes(null).ShouldBeNull();
}
[Fact]
public void ValidateAllowedConnectionTypes_ValidTypes_NoError()
{
var m = new HashSet<string> { "STANDARD", "WEBSOCKET" };
AuthHandler.ValidateAllowedConnectionTypes(m).ShouldBeNull();
}
[Fact]
public void ValidateAllowedConnectionTypes_UnknownType_ReturnsError()
{
var m = new HashSet<string> { "STANDARD", "someNewType" };
var err = AuthHandler.ValidateAllowedConnectionTypes(m);
err.ShouldNotBeNull();
err!.Message.ShouldContain("connection type");
}
[Fact]
public void ValidateAllowedConnectionTypes_NormalizesToUppercase()
{
var m = new HashSet<string> { "websocket", "mqtt" };
AuthHandler.ValidateAllowedConnectionTypes(m).ShouldBeNull();
m.ShouldContain("WEBSOCKET");
m.ShouldContain("MQTT");
m.ShouldNotContain("websocket");
m.ShouldNotContain("mqtt");
}
[Fact]
public void ValidateAllowedConnectionTypes_AllKnownTypes_NoError()
{
var m = new HashSet<string>
{
"STANDARD", "WEBSOCKET", "LEAFNODE",
"LEAFNODE_WS", "MQTT", "MQTT_WS", "IN_PROCESS",
};
AuthHandler.ValidateAllowedConnectionTypes(m).ShouldBeNull();
}
// =========================================================================
// ValidateNoAuthUser
// =========================================================================
[Fact]
public void ValidateNoAuthUser_EmptyNoAuthUser_NoError()
{
var opts = new ServerOptions();
AuthHandler.ValidateNoAuthUser(opts, "").ShouldBeNull();
}
[Fact]
public void ValidateNoAuthUser_WithTrustedOperators_ReturnsError()
{
var opts = new ServerOptions
{
TrustedOperators = [new object()],
};
var err = AuthHandler.ValidateNoAuthUser(opts, "foo");
err.ShouldNotBeNull();
err!.Message.ShouldContain("Trusted Operator");
}
[Fact]
public void ValidateNoAuthUser_NoUsersOrNkeys_ReturnsError()
{
var opts = new ServerOptions();
var err = AuthHandler.ValidateNoAuthUser(opts, "foo");
err.ShouldNotBeNull();
err!.Message.ShouldContain("users/nkeys are not defined");
}
[Fact]
public void ValidateNoAuthUser_UserFound_NoError()
{
var opts = new ServerOptions
{
Users = [new User { Username = "foo" }],
};
AuthHandler.ValidateNoAuthUser(opts, "foo").ShouldBeNull();
}
[Fact]
public void ValidateNoAuthUser_NkeyFound_NoError()
{
var opts = new ServerOptions
{
Nkeys = [new NkeyUser { Nkey = "NKEY1" }],
};
AuthHandler.ValidateNoAuthUser(opts, "NKEY1").ShouldBeNull();
}
[Fact]
public void ValidateNoAuthUser_UserNotFound_ReturnsError()
{
var opts = new ServerOptions
{
Users = [new User { Username = "bar" }],
};
var err = AuthHandler.ValidateNoAuthUser(opts, "foo");
err.ShouldNotBeNull();
err!.Message.ShouldContain("not present as user or nkey");
}
// =========================================================================
// ValidateAuth
// =========================================================================
[Fact]
public void ValidateAuth_ValidConfig_NoError()
{
var opts = new ServerOptions
{
Users =
[
new User
{
Username = "u1",
AllowedConnectionTypes = new HashSet<string> { "STANDARD" },
},
],
NoAuthUser = "u1",
};
AuthHandler.ValidateAuth(opts).ShouldBeNull();
}
[Fact]
public void ValidateAuth_InvalidConnectionType_ReturnsError()
{
var opts = new ServerOptions
{
Users =
[
new User
{
Username = "u1",
AllowedConnectionTypes = new HashSet<string> { "STANDARD", "BAD_TYPE" },
},
],
};
var err = AuthHandler.ValidateAuth(opts);
err.ShouldNotBeNull();
err!.Message.ShouldContain("connection type");
}
// =========================================================================
// DnsAltNameLabels + DnsAltNameMatches — Go test ID 148
// =========================================================================
[Theory]
[InlineData("foo", new[] { "nats://FOO" }, true)]
[InlineData("foo", new[] { "nats://.." }, false)]
[InlineData("foo", new[] { "nats://." }, false)]
[InlineData("Foo", new[] { "nats://foO" }, true)]
[InlineData("FOO", new[] { "nats://foo" }, true)]
[InlineData("foo1", new[] { "nats://bar" }, false)]
[InlineData("multi", new[] { "nats://m", "nats://mu", "nats://mul", "nats://multi" }, true)]
[InlineData("multi", new[] { "nats://multi", "nats://m", "nats://mu", "nats://mul" }, true)]
[InlineData("foo.bar", new[] { "nats://foo", "nats://foo.bar.bar", "nats://foo.baz" }, false)]
[InlineData("foo.Bar", new[] { "nats://foo", "nats://bar.foo", "nats://Foo.Bar" }, true)]
[InlineData("foo.*", new[] { "nats://foo", "nats://bar.foo", "nats://Foo.Bar" }, false)]
[InlineData("f*.bar", new[] { "nats://foo", "nats://bar.foo", "nats://Foo.Bar" }, false)]
[InlineData("*.bar", new[] { "nats://foo.bar" }, true)]
[InlineData("*", new[] { "nats://baz.bar", "nats://bar", "nats://z.y" }, true)]
[InlineData("*", new[] { "nats://bar" }, true)]
[InlineData("*", new[] { "nats://." }, false)]
[InlineData("*", new[] { "nats://" }, true)]
// NOTE: Go test cases {"*", ["*"], true} and {"bar.*", ["bar.*"], true} are omitted
// because .NET's Uri class does not preserve '*' in hostnames the same way Go's url.Parse does.
// Similarly, cases with leading dots like ".Y.local" and "..local" are omitted
// because .NET's Uri normalizes those hostnames differently.
[InlineData("*.Y-X-red-mgmt.default.svc", new[] { "nats://A.Y-X-red-mgmt.default.svc" }, true)]
[InlineData("*.Y-X-green-mgmt.default.svc", new[] { "nats://A.Y-X-green-mgmt.default.svc" }, true)]
[InlineData("*.Y-X-blue-mgmt.default.svc", new[] { "nats://A.Y-X-blue-mgmt.default.svc" }, true)]
[InlineData("Y-X-red-mgmt", new[] { "nats://Y-X-red-mgmt" }, true)]
[InlineData("Y-X-red-mgmt", new[] { "nats://X-X-red-mgmt" }, false)]
[InlineData("Y-X-red-mgmt", new[] { "nats://Y-X-green-mgmt" }, false)]
[InlineData("Y-X-red-mgmt", new[] { "nats://Y" }, false)]
[InlineData("Y-X-red-mgmt", new[] { "nats://Y-X" }, false)]
[InlineData("Y-X-red-mgmt", new[] { "nats://Y-X-red" }, false)]
[InlineData("Y-X-red-mgmt", new[] { "nats://X-red-mgmt" }, false)]
[InlineData("Y-X-green-mgmt", new[] { "nats://Y-X-green-mgmt" }, true)]
[InlineData("Y-X-blue-mgmt", new[] { "nats://Y-X-blue-mgmt" }, true)]
[InlineData("connect.Y.local", new[] { "nats://connect.Y.local" }, true)]
[InlineData("gcp.Y.local", new[] { "nats://gcp.Y.local" }, true)]
[InlineData("uswest1.gcp.Y.local", new[] { "nats://uswest1.gcp.Y.local" }, true)]
public void DnsAltNameMatches_MatchesCorrectly(string altName, string[] urlStrings, bool expected)
{
var urls = urlStrings.Select(s => new Uri(s)).ToArray();
var labels = AuthHandler.DnsAltNameLabels(altName);
AuthHandler.DnsAltNameMatches(labels, urls).ShouldBe(expected);
}
[Fact]
public void DnsAltNameMatches_NullUrl_Skipped()
{
var labels = AuthHandler.DnsAltNameLabels("foo");
var urls = new Uri?[] { null, new Uri("nats://foo") };
AuthHandler.DnsAltNameMatches(labels, urls!).ShouldBeTrue();
}
// =========================================================================
// WipeSlice
// =========================================================================
[Fact]
public void WipeSlice_FillsWithX()
{
var buf = new byte[] { 1, 2, 3, 4, 5 };
AuthHandler.WipeSlice(buf);
buf.ShouldAllBe(b => b == (byte)'x');
}
[Fact]
public void WipeSlice_EmptyBuffer_NoOp()
{
var buf = Array.Empty<byte>();
AuthHandler.WipeSlice(buf); // should not throw
}
// =========================================================================
// ConnectionTypes known check
// =========================================================================
[Theory]
[InlineData("STANDARD", true)]
[InlineData("WEBSOCKET", true)]
[InlineData("LEAFNODE", true)]
[InlineData("LEAFNODE_WS", true)]
[InlineData("MQTT", true)]
[InlineData("MQTT_WS", true)]
[InlineData("IN_PROCESS", true)]
[InlineData("UNKNOWN", false)]
[InlineData("", false)]
public void ConnectionTypes_IsKnown_DetectsCorrectly(string ct, bool expected)
{
AuthHandler.ConnectionTypes.IsKnown(ct).ShouldBe(expected);
}
}

View File

@@ -0,0 +1,246 @@
// 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.
using Shouldly;
using ZB.MOM.NatsNet.Server.Auth;
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
/// <summary>
/// Tests for auth type cloning and validation.
/// Mirrors Go auth_test.go: TestUserClone*, TestDNSAltNameMatching, and additional unit tests.
/// </summary>
public class AuthTypesTests
{
// -------------------------------------------------------------------------
// TestUserCloneNilPermissions — Go test ID 142
// -------------------------------------------------------------------------
[Fact]
public void UserClone_NilPermissions_ProducesDeepCopy()
{
var user = new User { Username = "foo", Password = "bar" };
var clone = user.Clone()!;
clone.Username.ShouldBe(user.Username);
clone.Password.ShouldBe(user.Password);
clone.Permissions.ShouldBeNull();
// Mutation should not affect original.
clone.Password = "baz";
user.Password.ShouldBe("bar");
}
// -------------------------------------------------------------------------
// TestUserClone — Go test ID 143
// -------------------------------------------------------------------------
[Fact]
public void UserClone_WithPermissions_ProducesDeepCopy()
{
var user = new User
{
Username = "foo",
Password = "bar",
Permissions = new Permissions
{
Publish = new SubjectPermission { Allow = ["foo"] },
Subscribe = new SubjectPermission { Allow = ["bar"] },
},
};
var clone = user.Clone()!;
clone.Username.ShouldBe("foo");
clone.Permissions.ShouldNotBeNull();
clone.Permissions!.Publish!.Allow.ShouldBe(["foo"]);
clone.Permissions.Subscribe!.Allow.ShouldBe(["bar"]);
// Mutating clone should not affect original.
clone.Permissions.Subscribe.Allow = ["baz"];
user.Permissions!.Subscribe!.Allow.ShouldBe(["bar"]);
}
// -------------------------------------------------------------------------
// TestUserClonePermissionsNoLists — Go test ID 144
// -------------------------------------------------------------------------
[Fact]
public void UserClone_EmptyPermissions_PublishSubscribeAreNull()
{
var user = new User
{
Username = "foo",
Password = "bar",
Permissions = new Permissions(),
};
var clone = user.Clone()!;
clone.Permissions.ShouldNotBeNull();
clone.Permissions!.Publish.ShouldBeNull();
clone.Permissions.Subscribe.ShouldBeNull();
}
// -------------------------------------------------------------------------
// TestUserCloneNoPermissions — Go test ID 145
// -------------------------------------------------------------------------
[Fact]
public void UserClone_NoPermissions_PermissionsIsNull()
{
var user = new User { Username = "foo", Password = "bar" };
var clone = user.Clone()!;
clone.Permissions.ShouldBeNull();
}
// -------------------------------------------------------------------------
// TestUserCloneNil — Go test ID 146
// -------------------------------------------------------------------------
[Fact]
public void UserClone_NilUser_ReturnsNull()
{
User? user = null;
var clone = user?.Clone();
clone.ShouldBeNull();
}
// -------------------------------------------------------------------------
// NkeyUser clone tests (additional coverage for NkeyUser.clone)
// -------------------------------------------------------------------------
[Fact]
public void NkeyUserClone_WithAllowedConnectionTypes_ProducesDeepCopy()
{
var nkey = new NkeyUser
{
Nkey = "NKEY123",
SigningKey = "SK1",
AllowedConnectionTypes = new HashSet<string> { "STANDARD", "WEBSOCKET" },
Permissions = new Permissions
{
Publish = new SubjectPermission { Allow = ["pub.>"] },
},
};
var clone = nkey.Clone()!;
clone.Nkey.ShouldBe("NKEY123");
clone.AllowedConnectionTypes.ShouldNotBeNull();
clone.AllowedConnectionTypes!.Count.ShouldBe(2);
clone.Permissions!.Publish!.Allow.ShouldBe(["pub.>"]);
// Mutating clone should not affect original.
clone.AllowedConnectionTypes.Add("MQTT");
nkey.AllowedConnectionTypes.Count.ShouldBe(2);
clone.Permissions.Publish.Allow = ["other.>"];
nkey.Permissions!.Publish!.Allow.ShouldBe(["pub.>"]);
}
[Fact]
public void NkeyUserClone_NilNkeyUser_ReturnsNull()
{
NkeyUser? nkey = null;
var clone = nkey?.Clone();
clone.ShouldBeNull();
}
// -------------------------------------------------------------------------
// SubjectPermission clone tests
// -------------------------------------------------------------------------
[Fact]
public void SubjectPermissionClone_WithAllowAndDeny_ProducesDeepCopy()
{
var perm = new SubjectPermission
{
Allow = ["foo.>", "bar.>"],
Deny = ["baz.>"],
};
var clone = perm.Clone();
clone.Allow.ShouldBe(["foo.>", "bar.>"]);
clone.Deny.ShouldBe(["baz.>"]);
clone.Allow.Add("extra");
perm.Allow.Count.ShouldBe(2);
}
[Fact]
public void SubjectPermissionClone_NullLists_StaysNull()
{
var perm = new SubjectPermission();
var clone = perm.Clone();
clone.Allow.ShouldBeNull();
clone.Deny.ShouldBeNull();
}
// -------------------------------------------------------------------------
// Permissions clone with Response
// -------------------------------------------------------------------------
[Fact]
public void PermissionsClone_WithResponse_ProducesDeepCopy()
{
var perms = new Permissions
{
Publish = new SubjectPermission { Allow = ["pub"] },
Subscribe = new SubjectPermission { Deny = ["deny"] },
Response = new ResponsePermission { MaxMsgs = 5, Expires = TimeSpan.FromMinutes(1) },
};
var clone = perms.Clone();
clone.Response.ShouldNotBeNull();
clone.Response!.MaxMsgs.ShouldBe(5);
clone.Response.Expires.ShouldBe(TimeSpan.FromMinutes(1));
// Mutating clone should not affect original.
clone.Response.MaxMsgs = 10;
perms.Response!.MaxMsgs.ShouldBe(5);
}
// -------------------------------------------------------------------------
// User clone with AllowedConnectionTypes
// -------------------------------------------------------------------------
[Fact]
public void UserClone_WithAllowedConnectionTypes_ProducesDeepCopy()
{
var user = new User
{
Username = "u1",
AllowedConnectionTypes = new HashSet<string> { "STANDARD" },
};
var clone = user.Clone()!;
clone.AllowedConnectionTypes.ShouldNotBeNull();
clone.AllowedConnectionTypes!.ShouldContain("STANDARD");
clone.AllowedConnectionTypes.Add("MQTT");
user.AllowedConnectionTypes.Count.ShouldBe(1);
}
// -------------------------------------------------------------------------
// User clone with Account (shared by reference)
// -------------------------------------------------------------------------
[Fact]
public void UserClone_AccountSharedByReference()
{
var acct = new Account { Name = "TestAccount" };
var user = new User { Username = "u1", Account = acct };
var clone = user.Clone()!;
// Account should be same reference.
clone.Account.ShouldBeSameAs(acct);
}
}

View File

@@ -0,0 +1,39 @@
using Shouldly;
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
namespace ZB.MOM.NatsNet.Server.Tests.Auth.CertificateIdentityProvider;
/// <summary>
/// Tests for the certidp module, mirroring certidp_test.go and ocsp_responder_test.go.
/// </summary>
public sealed class CertificateIdentityProviderTests
{
[Theory]
[InlineData(0, "good")]
[InlineData(1, "revoked")]
[InlineData(2, "unknown")]
[InlineData(42, "unknown")] // Invalid → defaults to unknown (never good)
public void GetStatusAssertionStr_ShouldMapCorrectly(int input, string expected)
{
// Mirror: TestGetStatusAssertionStr
OcspStatusAssertionExtensions.GetStatusAssertionStr(input).ShouldBe(expected);
}
[Fact]
public void EncodeOCSPRequest_ShouldProduceUrlSafeBase64()
{
// Mirror: TestEncodeOCSPRequest
var data = "test data for OCSP request"u8.ToArray();
var encoded = OcspResponder.EncodeOCSPRequest(data);
// Should not contain unescaped base64 chars that are URL-unsafe.
encoded.ShouldNotContain("+");
encoded.ShouldNotContain("/");
encoded.ShouldNotContain("=");
// Should round-trip: URL-unescape → base64-decode → original bytes.
var unescaped = Uri.UnescapeDataString(encoded);
var decoded = Convert.FromBase64String(unescaped);
decoded.ShouldBe(data);
}
}

View File

@@ -0,0 +1,82 @@
// Copyright 2016-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.
using System.Security.Authentication;
using System.Net.Security;
using Shouldly;
using ZB.MOM.NatsNet.Server.Auth;
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
/// <summary>
/// Tests for CipherSuites definitions.
/// Mirrors Go ciphersuites.go functionality.
/// </summary>
public class CipherSuitesTests
{
[Fact]
public void CipherMap_ContainsTls13Suites()
{
CipherSuites.CipherMap.ShouldNotBeEmpty();
// At minimum, TLS 1.3 suites should be present.
CipherSuites.CipherMap.ShouldContainKey("TLS_AES_256_GCM_SHA384");
CipherSuites.CipherMap.ShouldContainKey("TLS_AES_128_GCM_SHA256");
}
[Fact]
public void CipherMapById_ContainsTls13Suites()
{
CipherSuites.CipherMapById.ShouldNotBeEmpty();
CipherSuites.CipherMapById.ShouldContainKey(TlsCipherSuite.TLS_AES_256_GCM_SHA384);
}
[Fact]
public void CipherMap_CaseInsensitiveLookup()
{
// The map uses OrdinalIgnoreCase comparer.
CipherSuites.CipherMap.ShouldContainKey("tls_aes_256_gcm_sha384");
}
[Fact]
public void DefaultCipherSuites_ReturnsNonEmptyList()
{
var defaults = CipherSuites.DefaultCipherSuites();
defaults.ShouldNotBeEmpty();
defaults.Length.ShouldBeGreaterThan(0);
}
[Fact]
public void DefaultCipherSuites_ContainsSecureSuites()
{
var defaults = CipherSuites.DefaultCipherSuites();
defaults.ShouldContain(TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384);
defaults.ShouldContain(TlsCipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384);
}
[Fact]
public void CurvePreferenceMap_ContainsExpectedCurves()
{
CipherSuites.CurvePreferenceMap.ShouldContainKey("X25519");
CipherSuites.CurvePreferenceMap.ShouldContainKey("CurveP256");
CipherSuites.CurvePreferenceMap.ShouldContainKey("CurveP384");
CipherSuites.CurvePreferenceMap.ShouldContainKey("CurveP521");
}
[Fact]
public void DefaultCurvePreferences_ReturnsExpectedOrder()
{
var prefs = CipherSuites.DefaultCurvePreferences();
prefs.Length.ShouldBeGreaterThanOrEqualTo(4);
prefs[0].ShouldBe("X25519");
}
}

View File

@@ -0,0 +1,184 @@
// Copyright 2018-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.
using Shouldly;
using ZB.MOM.NatsNet.Server.Auth;
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
/// <summary>
/// Tests for JwtProcessor functions.
/// Mirrors Go jwt.go functionality for standalone testable functions.
/// </summary>
public class JwtProcessorTests
{
// =========================================================================
// JwtPrefix constant
// =========================================================================
[Fact]
public void JwtPrefix_IsCorrect()
{
JwtProcessor.JwtPrefix.ShouldBe("eyJ");
}
// =========================================================================
// WipeSlice
// =========================================================================
[Fact]
public void WipeSlice_FillsWithX()
{
var buf = new byte[] { 0x01, 0x02, 0x03 };
JwtProcessor.WipeSlice(buf);
buf.ShouldAllBe(b => b == (byte)'x');
}
[Fact]
public void WipeSlice_EmptyBuffer_NoOp()
{
var buf = Array.Empty<byte>();
JwtProcessor.WipeSlice(buf);
}
// =========================================================================
// ValidateSrc
// =========================================================================
[Fact]
public void ValidateSrc_NullCidrs_ReturnsFalse()
{
JwtProcessor.ValidateSrc(null, "192.168.1.1").ShouldBeFalse();
}
[Fact]
public void ValidateSrc_EmptyCidrs_ReturnsTrue()
{
JwtProcessor.ValidateSrc([], "192.168.1.1").ShouldBeTrue();
}
[Fact]
public void ValidateSrc_EmptyHost_ReturnsFalse()
{
JwtProcessor.ValidateSrc(["192.168.0.0/16"], "").ShouldBeFalse();
}
[Fact]
public void ValidateSrc_InvalidHost_ReturnsFalse()
{
JwtProcessor.ValidateSrc(["192.168.0.0/16"], "not-an-ip").ShouldBeFalse();
}
[Fact]
public void ValidateSrc_MatchingCidr_ReturnsTrue()
{
JwtProcessor.ValidateSrc(["192.168.0.0/16"], "192.168.1.100").ShouldBeTrue();
}
[Fact]
public void ValidateSrc_NonMatchingCidr_ReturnsFalse()
{
JwtProcessor.ValidateSrc(["10.0.0.0/8"], "192.168.1.100").ShouldBeFalse();
}
[Fact]
public void ValidateSrc_MultipleCidrs_MatchesAny()
{
var cidrs = new[] { "10.0.0.0/8", "192.168.0.0/16" };
JwtProcessor.ValidateSrc(cidrs, "192.168.1.100").ShouldBeTrue();
JwtProcessor.ValidateSrc(cidrs, "10.1.2.3").ShouldBeTrue();
JwtProcessor.ValidateSrc(cidrs, "172.16.0.1").ShouldBeFalse();
}
[Fact]
public void ValidateSrc_ExactMatch_SingleHost()
{
JwtProcessor.ValidateSrc(["192.168.1.100/32"], "192.168.1.100").ShouldBeTrue();
JwtProcessor.ValidateSrc(["192.168.1.100/32"], "192.168.1.101").ShouldBeFalse();
}
[Fact]
public void ValidateSrc_InvalidCidr_ReturnsFalse()
{
JwtProcessor.ValidateSrc(["not-a-cidr"], "192.168.1.1").ShouldBeFalse();
}
[Fact]
public void ValidateSrc_Ipv6_MatchingCidr()
{
JwtProcessor.ValidateSrc(["::1/128"], "::1").ShouldBeTrue();
JwtProcessor.ValidateSrc(["::1/128"], "::2").ShouldBeFalse();
}
[Fact]
public void ValidateSrc_MismatchedIpFamilies_ReturnsFalse()
{
// IPv6 CIDR with IPv4 address should not match.
JwtProcessor.ValidateSrc(["::1/128"], "127.0.0.1").ShouldBeFalse();
}
// =========================================================================
// ValidateTimes
// =========================================================================
[Fact]
public void ValidateTimes_NullRanges_ReturnsFalse()
{
var (allowed, remaining) = JwtProcessor.ValidateTimes(null);
allowed.ShouldBeFalse();
remaining.ShouldBe(TimeSpan.Zero);
}
[Fact]
public void ValidateTimes_EmptyRanges_ReturnsTrue()
{
var (allowed, remaining) = JwtProcessor.ValidateTimes([]);
allowed.ShouldBeTrue();
remaining.ShouldBe(TimeSpan.Zero);
}
[Fact]
public void ValidateTimes_CurrentTimeInRange_ReturnsTrue()
{
var now = DateTimeOffset.Now;
var start = now.AddMinutes(-30).ToString("HH:mm:ss");
var end = now.AddMinutes(30).ToString("HH:mm:ss");
var ranges = new[] { new TimeRange { Start = start, End = end } };
var (allowed, remaining) = JwtProcessor.ValidateTimes(ranges);
allowed.ShouldBeTrue();
remaining.TotalMinutes.ShouldBeGreaterThan(0);
remaining.TotalMinutes.ShouldBeLessThanOrEqualTo(30);
}
[Fact]
public void ValidateTimes_CurrentTimeOutOfRange_ReturnsFalse()
{
var now = DateTimeOffset.Now;
// Set a range entirely in the past today.
var start = now.AddHours(-3).ToString("HH:mm:ss");
var end = now.AddHours(-2).ToString("HH:mm:ss");
var ranges = new[] { new TimeRange { Start = start, End = end } };
var (allowed, _) = JwtProcessor.ValidateTimes(ranges);
allowed.ShouldBeFalse();
}
[Fact]
public void ValidateTimes_InvalidFormat_ReturnsFalse()
{
var ranges = new[] { new TimeRange { Start = "bad", End = "data" } };
var (allowed, _) = JwtProcessor.ValidateTimes(ranges);
allowed.ShouldBeFalse();
}
}

View File

@@ -0,0 +1,42 @@
using System.Runtime.InteropServices;
using Shouldly;
using ZB.MOM.NatsNet.Server.Auth;
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
public sealed class TpmKeyProviderTests
{
private static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
[Fact]
public void LoadJetStreamEncryptionKeyFromTpm_NonWindows_ThrowsPlatformNotSupportedException()
{
if (IsWindows)
return; // This test is for non-Windows only
var ex = Should.Throw<PlatformNotSupportedException>(() =>
TpmKeyProvider.LoadJetStreamEncryptionKeyFromTpm("", "keys.json", "password", 22));
ex.Message.ShouldContain("TPM");
}
[Fact]
public void LoadJetStreamEncryptionKeyFromTpm_Create_ShouldSucceed()
{
if (!IsWindows)
return; // Requires real TPM hardware on Windows
var tempFile = Path.Combine(Path.GetTempPath(), $"jskeys_{Guid.NewGuid():N}.json");
try
{
if (File.Exists(tempFile)) File.Delete(tempFile);
var key = TpmKeyProvider.LoadJetStreamEncryptionKeyFromTpm("", tempFile, "password", 22);
key.ShouldNotBeNullOrEmpty();
}
finally
{
if (File.Exists(tempFile)) File.Delete(tempFile);
}
}
}

View File

@@ -0,0 +1,320 @@
// Copyright 2012-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/client_test.go in the NATS server Go source.
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Protocol;
namespace ZB.MOM.NatsNet.Server.Tests;
/// <summary>
/// Standalone unit tests for <see cref="ClientConnection"/> helper functions.
/// Adapted from server/client_test.go.
/// </summary>
public sealed class ClientTests
{
// =========================================================================
// TestSplitSubjectQueue — Test ID 200
// =========================================================================
[Theory]
[InlineData("foo", "foo", null, false)]
[InlineData("foo bar", "foo", "bar", false)]
[InlineData(" foo bar ", "foo", "bar", false)]
[InlineData("foo bar", "foo", "bar", false)]
[InlineData("foo bar fizz", null, null, true)]
public void SplitSubjectQueue_TableDriven(
string sq, string? wantSubject, string? wantQueue, bool wantErr)
{
if (wantErr)
{
Should.Throw<Exception>(() => ClientConnection.SplitSubjectQueue(sq));
}
else
{
var (subject, queue) = ClientConnection.SplitSubjectQueue(sq);
subject.ShouldBe(wantSubject is null ? null : Encoding.ASCII.GetBytes(wantSubject));
queue.ShouldBe(wantQueue is null ? null : Encoding.ASCII.GetBytes(wantQueue));
}
}
// =========================================================================
// TestTypeString — Test ID 201
// =========================================================================
[Theory]
[InlineData(ClientKind.Client, "Client")]
[InlineData(ClientKind.Router, "Router")]
[InlineData(ClientKind.Gateway, "Gateway")]
[InlineData(ClientKind.Leaf, "Leafnode")]
[InlineData(ClientKind.JetStream, "JetStream")]
[InlineData(ClientKind.Account, "Account")]
[InlineData(ClientKind.System, "System")]
[InlineData((ClientKind)(-1), "Unknown Type")]
public void KindString_ReturnsExpectedString(ClientKind kind, string expected)
{
var c = new ClientConnection(kind);
c.KindString().ShouldBe(expected);
}
}
/// <summary>
/// Standalone unit tests for <see cref="NatsMessageHeaders"/> functions.
/// Adapted from server/client_test.go (header utility tests).
/// </summary>
public sealed class NatsMessageHeadersTests
{
// =========================================================================
// TestRemoveHeaderIfPrefixPresent — Test ID 247
// =========================================================================
[Fact]
public void RemoveHeaderIfPrefixPresent_RemovesMatchingHeaders()
{
byte[]? hdr = null;
hdr = NatsMessageHeaders.GenHeader(hdr, "a", "1");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedStream, "my-stream");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSeq, "22");
hdr = NatsMessageHeaders.GenHeader(hdr, "b", "2");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeq, "24");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastMsgId, "1");
hdr = NatsMessageHeaders.GenHeader(hdr, "c", "3");
hdr = NatsMessageHeaders.RemoveHeaderIfPrefixPresent(hdr!, "Nats-Expected-");
var expected = Encoding.ASCII.GetBytes("NATS/1.0\r\na: 1\r\nb: 2\r\nc: 3\r\n\r\n");
hdr.ShouldBe(expected);
}
// =========================================================================
// TestSliceHeader — Test ID 248
// =========================================================================
[Fact]
public void SliceHeader_ReturnsCorrectSlice()
{
byte[]? hdr = null;
hdr = NatsMessageHeaders.GenHeader(hdr, "a", "1");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedStream, "my-stream");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSeq, "22");
hdr = NatsMessageHeaders.GenHeader(hdr, "b", "2");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeq, "24");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastMsgId, "1");
hdr = NatsMessageHeaders.GenHeader(hdr, "c", "3");
var sliced = NatsMessageHeaders.SliceHeader(NatsHeaderConstants.JsExpectedLastSubjSeq, hdr!);
var copied = NatsMessageHeaders.GetHeader(NatsHeaderConstants.JsExpectedLastSubjSeq, hdr!);
sliced.ShouldNotBeNull();
sliced!.Value.Length.ShouldBe(2); // "24" is 2 bytes
copied.ShouldNotBeNull();
sliced.Value.ToArray().ShouldBe(copied!);
}
// =========================================================================
// TestSliceHeaderOrderingPrefix — Test ID 249
// =========================================================================
[Fact]
public void SliceHeader_OrderingPrefix_LongerHeaderDoesNotPreemptShorter()
{
byte[]? hdr = null;
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeqSubj, "foo");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeq, "24");
var sliced = NatsMessageHeaders.SliceHeader(NatsHeaderConstants.JsExpectedLastSubjSeq, hdr!);
var copied = NatsMessageHeaders.GetHeader(NatsHeaderConstants.JsExpectedLastSubjSeq, hdr!);
sliced.ShouldNotBeNull();
sliced!.Value.Length.ShouldBe(2);
copied.ShouldNotBeNull();
sliced.Value.ToArray().ShouldBe(copied!);
}
// =========================================================================
// TestSliceHeaderOrderingSuffix — Test ID 250
// =========================================================================
[Fact]
public void SliceHeader_OrderingSuffix_LongerHeaderDoesNotPreemptShorter()
{
byte[]? hdr = null;
hdr = NatsMessageHeaders.GenHeader(hdr, "Previous-Nats-Msg-Id", "user");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsMsgId, "control");
var sliced = NatsMessageHeaders.SliceHeader(NatsHeaderConstants.JsMsgId, hdr!);
var copied = NatsMessageHeaders.GetHeader(NatsHeaderConstants.JsMsgId, hdr!);
sliced.ShouldNotBeNull();
copied.ShouldNotBeNull();
sliced!.Value.ToArray().ShouldBe(copied!);
Encoding.ASCII.GetString(copied!).ShouldBe("control");
}
// =========================================================================
// TestRemoveHeaderIfPresentOrderingPrefix — Test ID 251
// =========================================================================
[Fact]
public void RemoveHeaderIfPresent_OrderingPrefix_OnlyRemovesExactKey()
{
byte[]? hdr = null;
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeqSubj, "foo");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeq, "24");
hdr = NatsMessageHeaders.RemoveHeaderIfPresent(hdr!, NatsHeaderConstants.JsExpectedLastSubjSeq);
var expected = NatsMessageHeaders.GenHeader(null, NatsHeaderConstants.JsExpectedLastSubjSeqSubj, "foo");
hdr!.ShouldBe(expected);
}
// =========================================================================
// TestRemoveHeaderIfPresentOrderingSuffix — Test ID 252
// =========================================================================
[Fact]
public void RemoveHeaderIfPresent_OrderingSuffix_OnlyRemovesExactKey()
{
byte[]? hdr = null;
hdr = NatsMessageHeaders.GenHeader(hdr, "Previous-Nats-Msg-Id", "user");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsMsgId, "control");
hdr = NatsMessageHeaders.RemoveHeaderIfPresent(hdr!, NatsHeaderConstants.JsMsgId);
var expected = NatsMessageHeaders.GenHeader(null, "Previous-Nats-Msg-Id", "user");
hdr!.ShouldBe(expected);
}
// =========================================================================
// TestMsgPartsCapsHdrSlice — Test ID 253
// =========================================================================
[Fact]
public void MsgParts_HeaderSliceIsIsolatedCopy()
{
const string hdrContent = NatsHeaderConstants.HdrLine + "Key1: Val1\r\nKey2: Val2\r\n\r\n";
const string msgBody = "hello\r\n";
var buf = Encoding.ASCII.GetBytes(hdrContent + msgBody);
var c = new ClientConnection(ClientKind.Client);
c.ParseCtx.Pa.HeaderSize = hdrContent.Length;
var (hdr, msg) = c.MsgParts(buf);
// Header and body should have correct content.
Encoding.ASCII.GetString(hdr).ShouldBe(hdrContent);
Encoding.ASCII.GetString(msg).ShouldBe(msgBody);
// hdr should be shorter than buf (cap(hdr) < cap(buf) in Go).
hdr.Length.ShouldBeLessThan(buf.Length);
// Appending to hdr should not affect msg.
var extended = hdr.Concat(Encoding.ASCII.GetBytes("test")).ToArray();
Encoding.ASCII.GetString(extended).ShouldBe(hdrContent + "test");
Encoding.ASCII.GetString(msg).ShouldBe("hello\r\n");
}
// =========================================================================
// TestSetHeaderDoesNotOverwriteUnderlyingBuffer — Test ID 254
// =========================================================================
[Theory]
[InlineData("Key1", "Val1Updated", "NATS/1.0\r\nKey1: Val1Updated\r\nKey2: Val2\r\n\r\n", true)]
[InlineData("Key1", "v1", "NATS/1.0\r\nKey1: v1\r\nKey2: Val2\r\n\r\n", false)]
[InlineData("Key3", "Val3", "NATS/1.0\r\nKey1: Val1\r\nKey2: Val2\r\nKey3: Val3\r\n\r\n", true)]
public void SetHeader_DoesNotOverwriteUnderlyingBuffer(
string key, string val, string expectedHdr, bool isNewBuf)
{
const string initialHdr = "NATS/1.0\r\nKey1: Val1\r\nKey2: Val2\r\n\r\n";
const string msgBody = "this is the message body\r\n";
var buf = new byte[initialHdr.Length + msgBody.Length];
Encoding.ASCII.GetBytes(initialHdr).CopyTo(buf, 0);
Encoding.ASCII.GetBytes(msgBody).CopyTo(buf, initialHdr.Length);
var hdrSlice = buf[..initialHdr.Length];
var msgSlice = buf[initialHdr.Length..];
var updatedHdr = NatsMessageHeaders.SetHeader(key, val, hdrSlice);
Encoding.ASCII.GetString(updatedHdr).ShouldBe(expectedHdr);
Encoding.ASCII.GetString(msgSlice).ShouldBe(msgBody);
if (isNewBuf)
{
// New allocation: original buf's header portion must be unchanged.
Encoding.ASCII.GetString(buf, 0, initialHdr.Length).ShouldBe(initialHdr);
}
else
{
// In-place update: C# array slices are copies (not views like Go), so buf
// is unchanged. However, hdrSlice (the array passed to SetHeader) IS
// modified in place via Buffer.BlockCopy.
Encoding.ASCII.GetString(hdrSlice, 0, expectedHdr.Length).ShouldBe(expectedHdr);
}
}
// =========================================================================
// TestSetHeaderOrderingPrefix — Test ID 255
// =========================================================================
[Theory]
[InlineData(true)]
[InlineData(false)]
public void SetHeader_OrderingPrefix_LongerHeaderDoesNotPreemptShorter(bool withSpaces)
{
byte[]? hdr = null;
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeqSubj, "foo");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeq, "24");
if (!withSpaces)
hdr = hdr!.Where(b => b != (byte)' ').ToArray();
hdr = NatsMessageHeaders.SetHeader(NatsHeaderConstants.JsExpectedLastSubjSeq, "12", hdr!);
byte[]? expected = null;
expected = NatsMessageHeaders.GenHeader(expected, NatsHeaderConstants.JsExpectedLastSubjSeqSubj, "foo");
expected = NatsMessageHeaders.GenHeader(expected, NatsHeaderConstants.JsExpectedLastSubjSeq, "12");
if (!withSpaces)
expected = expected!.Where(b => b != (byte)' ').ToArray();
hdr!.ShouldBe(expected!);
}
// =========================================================================
// TestSetHeaderOrderingSuffix — Test ID 256
// =========================================================================
[Theory]
[InlineData(true)]
[InlineData(false)]
public void SetHeader_OrderingSuffix_LongerHeaderDoesNotPreemptShorter(bool withSpaces)
{
byte[]? hdr = null;
hdr = NatsMessageHeaders.GenHeader(hdr, "Previous-Nats-Msg-Id", "user");
hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsMsgId, "control");
if (!withSpaces)
hdr = hdr!.Where(b => b != (byte)' ').ToArray();
hdr = NatsMessageHeaders.SetHeader(NatsHeaderConstants.JsMsgId, "other", hdr!);
byte[]? expected = null;
expected = NatsMessageHeaders.GenHeader(expected, "Previous-Nats-Msg-Id", "user");
expected = NatsMessageHeaders.GenHeader(expected, NatsHeaderConstants.JsMsgId, "other");
if (!withSpaces)
expected = expected!.Where(b => b != (byte)' ').ToArray();
hdr!.ShouldBe(expected!);
}
}

View File

@@ -0,0 +1,87 @@
// Copyright 2020-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.
using Shouldly;
using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.Tests.Foundation;
/// <summary>
/// Tests for <see cref="ErrorCtx"/> and <see cref="ErrorContextHelper"/>.
/// Mirrors server/errors_test.go: TestErrCtx (ID 297) and TestErrCtxWrapped (ID 298).
/// </summary>
public sealed class ServerErrorsTests
{
[Fact]
public void ErrCtx_ShouldPreserveOriginalMessageAndAddContext()
{
// Mirror: TestErrCtx
var ctx = "Extra context information";
var e = ErrorContextHelper.NewErrorCtx(ServerErrors.ErrWrongGateway, "{0}", ctx);
// Message should match the underlying error.
e.Message.ShouldBe(ServerErrors.ErrWrongGateway.Message);
// Must not be reference-equal to the sentinel.
e.ShouldNotBeSameAs(ServerErrors.ErrWrongGateway);
// ErrorIs should find the sentinel in the chain.
ErrorContextHelper.ErrorIs(e, ServerErrors.ErrWrongGateway).ShouldBeTrue();
// UnpackIfErrorCtx on a non-ctx error returns Message unchanged.
ErrorContextHelper.UnpackIfErrorCtx(ServerErrors.ErrWrongGateway)
.ShouldBe(ServerErrors.ErrWrongGateway.Message);
// UnpackIfErrorCtx should start with the original error message.
var trace = ErrorContextHelper.UnpackIfErrorCtx(e);
trace.ShouldStartWith(ServerErrors.ErrWrongGateway.Message);
// And end with the context string.
trace.ShouldEndWith(ctx);
}
[Fact]
public void ErrCtxWrapped_ShouldContainAllContextLayers()
{
// Mirror: TestErrCtxWrapped
var ctxO = "Original Ctx";
var eO = ErrorContextHelper.NewErrorCtx(ServerErrors.ErrWrongGateway, "{0}", ctxO);
var ctx = "Extra context information";
var e = ErrorContextHelper.NewErrorCtx(eO, "{0}", ctx);
// Message should still match the underlying error.
e.Message.ShouldBe(ServerErrors.ErrWrongGateway.Message);
// Must not be reference-equal to the sentinel.
e.ShouldNotBeSameAs(ServerErrors.ErrWrongGateway);
// ErrorIs should walk the chain.
ErrorContextHelper.ErrorIs(e, ServerErrors.ErrWrongGateway).ShouldBeTrue();
// UnpackIfErrorCtx on a non-ctx error returns Message unchanged.
ErrorContextHelper.UnpackIfErrorCtx(ServerErrors.ErrWrongGateway)
.ShouldBe(ServerErrors.ErrWrongGateway.Message);
var trace = ErrorContextHelper.UnpackIfErrorCtx(e);
// Must start with the original error.
trace.ShouldStartWith(ServerErrors.ErrWrongGateway.Message);
// Must end with the outermost context.
trace.ShouldEndWith(ctx);
// Must also contain the inner context.
trace.ShouldContain(ctxO);
}
}

View File

@@ -0,0 +1,80 @@
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
/// <summary>
/// Tests for <see cref="AccessTimeService"/>, mirroring ats_test.go.
/// </summary>
[Collection("AccessTimeService")]
public sealed class AccessTimeServiceTests : IDisposable
{
public AccessTimeServiceTests()
{
AccessTimeService.Reset();
}
public void Dispose()
{
AccessTimeService.Reset();
}
[Fact]
public void NotRunningValue_ShouldReturnNonZero()
{
// Mirror: TestNotRunningValue
// No registrants; AccessTime() must still return a non-zero value.
var at = AccessTimeService.AccessTime();
at.ShouldBeGreaterThan(0);
// Should be stable (no background timer updating it).
var atn = AccessTimeService.AccessTime();
atn.ShouldBe(at);
}
[Fact]
public async Task RegisterAndUnregister_ShouldManageLifetime()
{
// Mirror: TestRegisterAndUnregister
AccessTimeService.Register();
var at = AccessTimeService.AccessTime();
at.ShouldBeGreaterThan(0);
// Background timer should update the time.
await Task.Delay(AccessTimeService.TickInterval * 3);
var atn = AccessTimeService.AccessTime();
atn.ShouldBeGreaterThan(at);
// Unregister; timer should stop.
AccessTimeService.Unregister();
await Task.Delay(AccessTimeService.TickInterval);
at = AccessTimeService.AccessTime();
await Task.Delay(AccessTimeService.TickInterval * 3);
atn = AccessTimeService.AccessTime();
atn.ShouldBe(at);
// Re-register should restart the timer.
AccessTimeService.Register();
try
{
at = AccessTimeService.AccessTime();
await Task.Delay(AccessTimeService.TickInterval * 3);
atn = AccessTimeService.AccessTime();
atn.ShouldBeGreaterThan(at);
}
finally
{
AccessTimeService.Unregister();
}
}
[Fact]
public void UnbalancedUnregister_ShouldThrow()
{
// Mirror: TestUnbalancedUnregister
Should.Throw<InvalidOperationException>(() => AccessTimeService.Unregister());
}
}

View File

@@ -0,0 +1,79 @@
// Copyright 2018-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.
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
/// <summary>
/// Tests for <see cref="ClosedRingBuffer"/>.
/// Mirrors server/ring_test.go: TestRBAppendAndLenAndTotal (ID 2794)
/// and TestRBclosedClients (ID 2795).
/// </summary>
public sealed class ClosedRingBufferTests
{
[Fact]
public void AppendAndLenAndTotal_ShouldTrackCorrectly()
{
// Mirror: TestRBAppendAndLenAndTotal
var rb = new ClosedRingBuffer(10);
for (var i = 0; i < 5; i++)
rb.Append(new ClosedClient());
rb.Len().ShouldBe(5);
rb.TotalConns().ShouldBe(5UL);
for (var i = 0; i < 25; i++)
rb.Append(new ClosedClient());
rb.Len().ShouldBe(10);
rb.TotalConns().ShouldBe(30UL);
}
[Fact]
public void ClosedClients_ShouldReturnChronologicalOrder()
{
// Mirror: TestRBclosedClients
var rb = new ClosedRingBuffer(10);
// Build master list with identifiable user strings.
const int max = 100;
var master = new ClosedClient[max];
for (var i = 1; i <= max; i++)
master[i - 1] = new ClosedClient { User = i.ToString() };
var ui = 0;
void AddConn()
{
ui++;
rb.Append(new ClosedClient { User = ui.ToString() });
}
for (var i = 0; i < max; i++)
{
AddConn();
var ccs = rb.ClosedClients();
var start = (int)rb.TotalConns() - ccs.Length;
var ms = master[start..(start + ccs.Length)];
// Verify order matches master using User strings.
ccs.Length.ShouldBe(ms.Length);
for (var j = 0; j < ccs.Length; j++)
ccs[j]!.User.ShouldBe(ms[j].User, $"iteration {i}, slot {j}");
}
}
}

View File

@@ -0,0 +1,511 @@
// Copyright 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.
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server.Tests.Internal.DataStructures;
/// <summary>
/// Ports all 21 tests from Go's gsl/gsl_test.go.
/// </summary>
public sealed class GenericSublistTests
{
// -------------------------------------------------------------------------
// Helpers (mirror Go's require_* functions)
// -------------------------------------------------------------------------
/// <summary>
/// Counts how many values the sublist matches for <paramref name="subject"/>
/// and asserts that count equals <paramref name="expected"/>.
/// Mirrors Go's <c>require_Matches</c>.
/// </summary>
private static void RequireMatches<T>(GenericSublist<T> s, string subject, int expected)
where T : notnull
{
var matches = 0;
s.Match(subject, _ => matches++);
matches.ShouldBe(expected);
}
// -------------------------------------------------------------------------
// TestGenericSublistInit
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistInit()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Count.ShouldBe(0u);
}
// -------------------------------------------------------------------------
// TestGenericSublistInsertCount
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistInsertCount()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("foo", EmptyStruct.Value);
s.Insert("bar", EmptyStruct.Value);
s.Insert("foo.bar", EmptyStruct.Value);
s.Count.ShouldBe(3u);
}
// -------------------------------------------------------------------------
// TestGenericSublistSimple
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistSimple()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("foo", EmptyStruct.Value);
RequireMatches(s, "foo", 1);
}
// -------------------------------------------------------------------------
// TestGenericSublistSimpleMultiTokens
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistSimpleMultiTokens()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("foo.bar.baz", EmptyStruct.Value);
RequireMatches(s, "foo.bar.baz", 1);
}
// -------------------------------------------------------------------------
// TestGenericSublistPartialWildcard
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistPartialWildcard()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("a.b.c", EmptyStruct.Value);
s.Insert("a.*.c", EmptyStruct.Value);
RequireMatches(s, "a.b.c", 2);
}
// -------------------------------------------------------------------------
// TestGenericSublistPartialWildcardAtEnd
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistPartialWildcardAtEnd()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("a.b.c", EmptyStruct.Value);
s.Insert("a.b.*", EmptyStruct.Value);
RequireMatches(s, "a.b.c", 2);
}
// -------------------------------------------------------------------------
// TestGenericSublistFullWildcard
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistFullWildcard()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("a.b.c", EmptyStruct.Value);
s.Insert("a.>", EmptyStruct.Value);
RequireMatches(s, "a.b.c", 2);
RequireMatches(s, "a.>", 1);
}
// -------------------------------------------------------------------------
// TestGenericSublistRemove
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemove()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("a.b.c.d", EmptyStruct.Value);
s.Count.ShouldBe(1u);
RequireMatches(s, "a.b.c.d", 1);
s.Remove("a.b.c.d", EmptyStruct.Value);
s.Count.ShouldBe(0u);
RequireMatches(s, "a.b.c.d", 0);
}
// -------------------------------------------------------------------------
// TestGenericSublistRemoveWildcard
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemoveWildcard()
{
var s = GenericSublist<int>.NewSublist();
s.Insert("a.b.c.d", 11);
s.Insert("a.b.*.d", 22);
s.Insert("a.b.>", 33);
s.Count.ShouldBe(3u);
RequireMatches(s, "a.b.c.d", 3);
s.Remove("a.b.*.d", 22);
s.Count.ShouldBe(2u);
RequireMatches(s, "a.b.c.d", 2);
s.Remove("a.b.>", 33);
s.Count.ShouldBe(1u);
RequireMatches(s, "a.b.c.d", 1);
s.Remove("a.b.c.d", 11);
s.Count.ShouldBe(0u);
RequireMatches(s, "a.b.c.d", 0);
}
// -------------------------------------------------------------------------
// TestGenericSublistRemoveCleanup
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemoveCleanup()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.NumLevels().ShouldBe(0);
s.Insert("a.b.c.d.e.f", EmptyStruct.Value);
s.NumLevels().ShouldBe(6);
s.Remove("a.b.c.d.e.f", EmptyStruct.Value);
s.NumLevels().ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestGenericSublistRemoveCleanupWildcards
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemoveCleanupWildcards()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.NumLevels().ShouldBe(0);
s.Insert("a.b.*.d.e.>", EmptyStruct.Value);
s.NumLevels().ShouldBe(6);
s.Remove("a.b.*.d.e.>", EmptyStruct.Value);
s.NumLevels().ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestGenericSublistInvalidSubjectsInsert
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistInvalidSubjectsInsert()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
// Insert, or subscriptions, can have wildcards, but not empty tokens,
// and can not have a FWC that is not the terminal token.
Should.Throw<ArgumentException>(() => s.Insert(".foo", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Insert("foo.", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Insert("foo..bar", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Insert("foo.bar..baz", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Insert("foo.>.baz", EmptyStruct.Value));
}
// -------------------------------------------------------------------------
// TestGenericSublistBadSubjectOnRemove
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistBadSubjectOnRemove()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
Should.Throw<ArgumentException>(() => s.Insert("a.b..d", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Remove("a.b..d", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Remove("a.>.b", EmptyStruct.Value));
}
// -------------------------------------------------------------------------
// TestGenericSublistTwoTokenPubMatchSingleTokenSub
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistTwoTokenPubMatchSingleTokenSub()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("foo", EmptyStruct.Value);
RequireMatches(s, "foo", 1);
RequireMatches(s, "foo.bar", 0);
}
// -------------------------------------------------------------------------
// TestGenericSublistInsertWithWildcardsAsLiterals
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistInsertWithWildcardsAsLiterals()
{
var s = GenericSublist<int>.NewSublist();
var subjects = new[] { "foo.*-", "foo.>-" };
for (var i = 0; i < subjects.Length; i++)
{
var subject = subjects[i];
s.Insert(subject, i);
RequireMatches(s, "foo.bar", 0);
RequireMatches(s, subject, 1);
}
}
// -------------------------------------------------------------------------
// TestGenericSublistRemoveWithWildcardsAsLiterals
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemoveWithWildcardsAsLiterals()
{
var s = GenericSublist<int>.NewSublist();
var subjects = new[] { "foo.*-", "foo.>-" };
for (var i = 0; i < subjects.Length; i++)
{
var subject = subjects[i];
s.Insert(subject, i);
RequireMatches(s, "foo.bar", 0);
RequireMatches(s, subject, 1);
Should.Throw<KeyNotFoundException>(() => s.Remove("foo.bar", i));
s.Count.ShouldBe(1u);
s.Remove(subject, i);
s.Count.ShouldBe(0u);
}
}
// -------------------------------------------------------------------------
// TestGenericSublistMatchWithEmptyTokens
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistMatchWithEmptyTokens()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert(">", EmptyStruct.Value);
var subjects = new[]
{
".foo", "..foo", "foo..", "foo.", "foo..bar", "foo...bar"
};
foreach (var subject in subjects)
{
RequireMatches(s, subject, 0);
}
}
// -------------------------------------------------------------------------
// TestGenericSublistHasInterest
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistHasInterest()
{
var s = GenericSublist<int>.NewSublist();
s.Insert("foo", 11);
// Expect to find that "foo" matches but "bar" doesn't.
s.HasInterest("foo").ShouldBeTrue();
s.HasInterest("bar").ShouldBeFalse();
// Call Match on a subject we know there is no match.
RequireMatches(s, "bar", 0);
s.HasInterest("bar").ShouldBeFalse();
// Remove fooSub and check interest again.
s.Remove("foo", 11);
s.HasInterest("foo").ShouldBeFalse();
// Try with some wildcards.
s.Insert("foo.*", 22);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.bar.baz").ShouldBeFalse();
// Remove sub, there should be no interest.
s.Remove("foo.*", 22);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeFalse();
s.HasInterest("foo.bar.baz").ShouldBeFalse();
s.Insert("foo.>", 33);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.bar.baz").ShouldBeTrue();
s.Remove("foo.>", 33);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeFalse();
s.HasInterest("foo.bar.baz").ShouldBeFalse();
s.Insert("*.>", 44);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.baz").ShouldBeTrue();
s.Remove("*.>", 44);
s.Insert("*.bar", 55);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.baz").ShouldBeFalse();
s.Remove("*.bar", 55);
s.Insert("*", 66);
s.HasInterest("foo").ShouldBeTrue();
s.HasInterest("foo.bar").ShouldBeFalse();
s.Remove("*", 66);
}
// -------------------------------------------------------------------------
// TestGenericSublistHasInterestOverlapping
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistHasInterestOverlapping()
{
var s = GenericSublist<int>.NewSublist();
s.Insert("stream.A.child", 11);
s.Insert("stream.*", 11);
s.HasInterest("stream.A.child").ShouldBeTrue();
s.HasInterest("stream.A").ShouldBeTrue();
}
// -------------------------------------------------------------------------
// TestGenericSublistHasInterestStartingInRace
// Tests that HasInterestStartingIn is safe to call concurrently with
// modifications to the sublist. Mirrors Go's goroutine test using Tasks.
// -------------------------------------------------------------------------
[Fact]
public async Task TestGenericSublistHasInterestStartingInRace()
{
var s = GenericSublist<int>.NewSublist();
// Pre-populate with some patterns.
for (var i = 0; i < 10; i++)
{
s.Insert("foo.bar.baz", i);
s.Insert("foo.*.baz", i + 10);
s.Insert("foo.>", i + 20);
}
const int iterations = 1000;
// Task 1: repeatedly call HasInterestStartingIn.
var task1 = Task.Run(() =>
{
for (var i = 0; i < iterations; i++)
{
s.HasInterestStartingIn("foo");
s.HasInterestStartingIn("foo.bar");
s.HasInterestStartingIn("foo.bar.baz");
s.HasInterestStartingIn("other.subject");
}
});
// Task 2: repeatedly modify the sublist.
var task2 = Task.Run(() =>
{
for (var i = 0; i < iterations; i++)
{
var val = 1000 + i;
var dynSubject = "test.subject." + (char)('a' + i % 26);
s.Insert(dynSubject, val);
s.Insert("foo.*.test", val);
// Remove may fail if not found (concurrent), so swallow KeyNotFoundException.
try { s.Remove(dynSubject, val); } catch (KeyNotFoundException) { }
try { s.Remove("foo.*.test", val); } catch (KeyNotFoundException) { }
}
});
// Task 3: also call HasInterest (which does lock).
var task3 = Task.Run(() =>
{
for (var i = 0; i < iterations; i++)
{
s.HasInterest("foo.bar.baz");
s.HasInterest("foo.something.baz");
}
});
await Task.WhenAll(task1, task2, task3);
}
// -------------------------------------------------------------------------
// TestGenericSublistNumInterest
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistNumInterest()
{
var s = GenericSublist<int>.NewSublist();
s.Insert("foo", 11);
void RequireNumInterest(string subj, int expected)
{
RequireMatches(s, subj, expected);
s.NumInterest(subj).ShouldBe(expected);
}
// Expect to find that "foo" matches but "bar" doesn't.
RequireNumInterest("foo", 1);
RequireNumInterest("bar", 0);
// Remove fooSub and check interest again.
s.Remove("foo", 11);
RequireNumInterest("foo", 0);
// Try with some wildcards.
s.Insert("foo.*", 22);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 0);
// Remove sub, there should be no interest.
s.Remove("foo.*", 22);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 0);
RequireNumInterest("foo.bar.baz", 0);
s.Insert("foo.>", 33);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 1);
s.Remove("foo.>", 33);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 0);
RequireNumInterest("foo.bar.baz", 0);
s.Insert("*.>", 44);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 1);
s.Remove("*.>", 44);
s.Insert("*.bar", 55);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 0);
s.Remove("*.bar", 55);
s.Insert("*", 66);
RequireNumInterest("foo", 1);
RequireNumInterest("foo.bar", 0);
s.Remove("*", 66);
}
}

View File

@@ -0,0 +1,238 @@
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server.Tests.Internal.DataStructures;
/// <summary>
/// Tests for <see cref="HashWheel"/>, mirroring thw_test.go (functional tests only;
/// benchmarks are omitted as they require BenchmarkDotNet).
/// </summary>
public sealed class HashWheelTests
{
private static readonly long Second = 1_000_000_000L; // nanoseconds
[Fact]
public void HashWheelBasics_ShouldSucceed()
{
// Mirror: TestHashWheelBasics
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
var seq = 1UL;
var expires = now + 5 * Second;
hw.Add(seq, expires);
hw.Count.ShouldBe(1UL);
// Remove non-existent sequence.
Should.Throw<InvalidOperationException>(() => hw.Remove(999, expires));
hw.Count.ShouldBe(1UL);
// Remove properly.
hw.Remove(seq, expires);
hw.Count.ShouldBe(0UL);
// Already gone.
Should.Throw<InvalidOperationException>(() => hw.Remove(seq, expires));
hw.Count.ShouldBe(0UL);
}
[Fact]
public void HashWheelUpdate_ShouldSucceed()
{
// Mirror: TestHashWheelUpdate
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
var oldExpires = now + 5 * Second;
var newExpires = now + 10 * Second;
hw.Add(1, oldExpires);
hw.Count.ShouldBe(1UL);
hw.Update(1, oldExpires, newExpires);
hw.Count.ShouldBe(1UL);
// Old position gone.
Should.Throw<InvalidOperationException>(() => hw.Remove(1, oldExpires));
hw.Count.ShouldBe(1UL);
// New position exists.
hw.Remove(1, newExpires);
hw.Count.ShouldBe(0UL);
}
[Fact]
public void HashWheelExpiration_ShouldExpireOnly_AlreadyExpired()
{
// Mirror: TestHashWheelExpiration
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
var seqs = new Dictionary<ulong, long>
{
[1] = now - 1 * Second, // already expired
[2] = now + 1 * Second,
[3] = now + 10 * Second,
[4] = now + 60 * Second,
};
foreach (var (s, exp) in seqs)
hw.Add(s, exp);
hw.Count.ShouldBe((ulong)seqs.Count);
var expired = new HashSet<ulong>();
hw.ExpireTasksInternal(now, (s, _) => { expired.Add(s); return true; });
expired.Count.ShouldBe(1);
expired.ShouldContain(1UL);
hw.Count.ShouldBe(3UL);
}
[Fact]
public void HashWheelManualExpiration_ShouldRespectCallbackReturn()
{
// Mirror: TestHashWheelManualExpiration
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
for (var s = 1UL; s <= 4; s++)
hw.Add(s, now);
hw.Count.ShouldBe(4UL);
// Iterate without removing.
var expired = new Dictionary<ulong, ulong>();
for (var i = 0UL; i <= 1; i++)
{
hw.ExpireTasksInternal(now, (s, _) => { expired.TryAdd(s, 0); expired[s]++; return false; });
expired.Count.ShouldBe(4);
expired[1].ShouldBe(1 + i);
expired[2].ShouldBe(1 + i);
expired[3].ShouldBe(1 + i);
expired[4].ShouldBe(1 + i);
hw.Count.ShouldBe(4UL);
}
// Remove only even sequences.
for (var i = 0UL; i <= 1; i++)
{
hw.ExpireTasksInternal(now, (s, _) => { expired.TryAdd(s, 0); expired[s]++; return s % 2 == 0; });
expired[1].ShouldBe(3 + i);
expired[2].ShouldBe(3UL);
expired[3].ShouldBe(3 + i);
expired[4].ShouldBe(3UL);
hw.Count.ShouldBe(2UL);
}
// Manually remove remaining.
hw.Remove(1, now);
hw.Remove(3, now);
hw.Count.ShouldBe(0UL);
}
[Fact]
public void HashWheelExpirationLargerThanWheel_ShouldExpireAll()
{
// Mirror: TestHashWheelExpirationLargerThanWheel
const int WheelMask = (1 << 12) - 1;
var hw = HashWheel.NewHashWheel();
hw.Add(1, 0);
hw.Add(2, Second);
hw.Count.ShouldBe(2UL);
// Timestamp large enough to wrap the entire wheel.
var nowWrapped = Second * WheelMask;
var expired = new HashSet<ulong>();
hw.ExpireTasksInternal(nowWrapped, (s, _) => { expired.Add(s); return true; });
expired.Count.ShouldBe(2);
hw.Count.ShouldBe(0UL);
}
[Fact]
public void HashWheelNextExpiration_ShouldReturnEarliest()
{
// Mirror: TestHashWheelNextExpiration
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
var seqs = new Dictionary<ulong, long>
{
[1] = now + 5 * Second,
[2] = now + 3 * Second, // earliest
[3] = now + 10 * Second,
};
foreach (var (s, exp) in seqs)
hw.Add(s, exp);
var tick = now + 6 * Second;
hw.GetNextExpiration(tick).ShouldBe(seqs[2]);
var empty = HashWheel.NewHashWheel();
empty.GetNextExpiration(now + Second).ShouldBe(long.MaxValue);
}
[Fact]
public void HashWheelStress_ShouldHandleLargeScale()
{
// Mirror: TestHashWheelStress
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
const int numSeqs = 100_000;
for (var seq = 0; seq < numSeqs; seq++)
{
var exp = now + (long)seq * Second;
hw.Add((ulong)seq, exp);
}
// Update even sequences.
for (var seq = 0; seq < numSeqs; seq += 2)
{
var oldExp = now + (long)seq * Second;
var newExp = now + (long)(seq + numSeqs) * Second;
hw.Update((ulong)seq, oldExp, newExp);
}
// Remove odd sequences.
for (var seq = 1; seq < numSeqs; seq += 2)
{
var exp = now + (long)seq * Second;
hw.Remove((ulong)seq, exp);
}
}
[Fact]
public void HashWheelEncodeDecode_ShouldRoundTrip()
{
// Mirror: TestHashWheelEncodeDecode
var hw = HashWheel.NewHashWheel();
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
const int numSeqs = 100_000;
for (var seq = 0; seq < numSeqs; seq++)
{
var exp = now + (long)seq * Second;
hw.Add((ulong)seq, exp);
}
var b = hw.Encode(12345);
b.Length.ShouldBeGreaterThan(17);
var nhw = HashWheel.NewHashWheel();
var stamp = nhw.Decode(b);
stamp.ShouldBe(12345UL);
// Lowest expiry should match.
hw.GetNextExpiration(long.MaxValue).ShouldBe(nhw.GetNextExpiration(long.MaxValue));
// Verify all entries transferred by removing them from nhw.
for (var seq = 0; seq < numSeqs; seq++)
{
var exp = now + (long)seq * Second;
nhw.Remove((ulong)seq, exp); // throws if missing
}
nhw.Count.ShouldBe(0UL);
}
}

View File

@@ -0,0 +1,392 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server.Tests.Internal.DataStructures;
public sealed class SequenceSetTests
{
private static readonly int NumEntries = SequenceSet.NumEntries; // 2048
// --- Basic operations ---
[Fact]
public void SeqSetBasics_ShouldSucceed()
{
var ss = new SequenceSet();
var seqs = new ulong[] { 22, 222, 2000, 2, 2, 4 };
foreach (var seq in seqs)
{
ss.Insert(seq);
ss.Exists(seq).ShouldBeTrue();
}
ss.Nodes.ShouldBe(1);
ss.Size.ShouldBe(seqs.Length - 1); // one duplicate
var (lh, rh) = ss.Heights();
lh.ShouldBe(0);
rh.ShouldBe(0);
}
[Fact]
public void SeqSetLeftLean_ShouldSucceed()
{
var ss = new SequenceSet();
for (var i = (ulong)(4 * NumEntries); i > 0; i--)
ss.Insert(i);
ss.Nodes.ShouldBe(5);
ss.Size.ShouldBe(4 * NumEntries);
var (lh, rh) = ss.Heights();
lh.ShouldBe(2);
rh.ShouldBe(1);
}
[Fact]
public void SeqSetRightLean_ShouldSucceed()
{
var ss = new SequenceSet();
for (var i = 0UL; i < (ulong)(4 * NumEntries); i++)
ss.Insert(i);
ss.Nodes.ShouldBe(4);
ss.Size.ShouldBe(4 * NumEntries);
var (lh, rh) = ss.Heights();
lh.ShouldBe(1);
rh.ShouldBe(2);
}
[Fact]
public void SeqSetCorrectness_ShouldSucceed()
{
var num = 100_000;
var maxVal = 500_000;
var rng = new Random(42);
var reference = new HashSet<ulong>(num);
var ss = new SequenceSet();
for (var i = 0; i < num; i++)
{
var n = (ulong)rng.NextInt64(maxVal + 1);
ss.Insert(n);
reference.Add(n);
}
for (var i = 0UL; i <= (ulong)maxVal; i++)
ss.Exists(i).ShouldBe(reference.Contains(i));
}
[Fact]
public void SeqSetRange_ShouldSucceed()
{
var num = 2 * NumEntries + 22;
var nums = new List<ulong>(num);
for (var i = 0; i < num; i++)
nums.Add((ulong)i);
var rng = new Random(42);
for (var i = nums.Count - 1; i > 0; i--)
{
var j = rng.Next(i + 1);
(nums[i], nums[j]) = (nums[j], nums[i]);
}
var ss = new SequenceSet();
foreach (var n in nums)
ss.Insert(n);
var collected = new List<ulong>();
ss.Range(n => { collected.Add(n); return true; });
collected.Count.ShouldBe(num);
for (var i = 0; i < num; i++)
collected[i].ShouldBe((ulong)i);
// Test early termination.
collected.Clear();
ss.Range(n =>
{
if (n >= 10) return false;
collected.Add(n);
return true;
});
collected.Count.ShouldBe(10);
for (var i = 0UL; i < 10; i++)
collected[(int)i].ShouldBe(i);
}
[Fact]
public void SeqSetDelete_ShouldSucceed()
{
var ss = new SequenceSet();
var seqs = new ulong[] { 22, 222, 2222, 2, 2, 4 };
foreach (var seq in seqs)
ss.Insert(seq);
foreach (var seq in seqs)
{
ss.Delete(seq);
ss.Exists(seq).ShouldBeFalse();
}
ss.Root.ShouldBeNull();
}
[Fact]
public void SeqSetInsertAndDeletePedantic_ShouldSucceed()
{
var ss = new SequenceSet();
var num = 50 * NumEntries + 22;
var nums = new List<ulong>(num);
for (var i = 0; i < num; i++)
nums.Add((ulong)i);
var rng = new Random(42);
for (var i = nums.Count - 1; i > 0; i--)
{
var j = rng.Next(i + 1);
(nums[i], nums[j]) = (nums[j], nums[i]);
}
void AssertBalanced()
{
SequenceSet.Node.NodeIter(ss.Root, n =>
{
if (n != null && n.Height != (SequenceSet.Node.BalanceFactor(n) == int.MinValue ? 0 : 0))
{
// Height check: verify height equals max child height + 1
var expectedH = 1 + Math.Max(n.Left?.Height ?? 0, n.Right?.Height ?? 0);
n.Height.ShouldBe(expectedH);
}
});
var bf = SequenceSet.Node.BalanceFactor(ss.Root);
(bf is >= -1 and <= 1).ShouldBeTrue();
}
foreach (var n in nums)
{
ss.Insert(n);
AssertBalanced();
}
ss.Root.ShouldNotBeNull();
foreach (var n in nums)
{
ss.Delete(n);
AssertBalanced();
ss.Exists(n).ShouldBeFalse();
if (ss.Size > 0)
ss.Root.ShouldNotBeNull();
}
ss.Root.ShouldBeNull();
}
[Fact]
public void SeqSetMinMax_ShouldSucceed()
{
var ss = new SequenceSet();
var seqs = new ulong[] { 22, 222, 2222, 2, 2, 4 };
foreach (var seq in seqs)
ss.Insert(seq);
var (min, max) = ss.MinMax();
min.ShouldBe(2UL);
max.ShouldBe(2222UL);
ss.Empty();
var num = 22 * NumEntries + 22;
var nums = new List<ulong>(num);
for (var i = 0; i < num; i++)
nums.Add((ulong)i);
var rng = new Random(42);
for (var i = nums.Count - 1; i > 0; i--)
{
var j = rng.Next(i + 1);
(nums[i], nums[j]) = (nums[j], nums[i]);
}
foreach (var n in nums)
ss.Insert(n);
(min, max) = ss.MinMax();
min.ShouldBe(0UL);
max.ShouldBe((ulong)(num - 1));
}
[Fact]
public void SeqSetClone_ShouldSucceed()
{
var num = 100_000;
var maxVal = 500_000;
var rng = new Random(42);
var ss = new SequenceSet();
for (var i = 0; i < num; i++)
ss.Insert((ulong)rng.NextInt64(maxVal + 1));
var ssc = ss.Clone();
ssc.Size.ShouldBe(ss.Size);
ssc.Nodes.ShouldBe(ss.Nodes);
}
[Fact]
public void SeqSetUnion_ShouldSucceed()
{
var ss1 = new SequenceSet();
var seqs1 = new ulong[] { 22, 222, 2222, 2, 2, 4 };
foreach (var seq in seqs1) ss1.Insert(seq);
var ss2 = new SequenceSet();
var seqs2 = new ulong[] { 33, 333, 3333, 3, 33_333, 333_333 };
foreach (var seq in seqs2) ss2.Insert(seq);
var ss = SequenceSet.UnionSets(ss1, ss2);
ss.ShouldNotBeNull();
ss!.Size.ShouldBe(11);
foreach (var n in seqs1) ss.Exists(n).ShouldBeTrue();
foreach (var n in seqs2) ss.Exists(n).ShouldBeTrue();
}
[Fact]
public void SeqSetFirst_ShouldSucceed()
{
var seqs = new ulong[] { 22, 222, 2222, 222_222 };
foreach (var seq in seqs)
{
var ss = new SequenceSet();
ss.Insert(seq);
ss.Root.ShouldNotBeNull();
ss.Root!.Base.ShouldBe((seq / (ulong)NumEntries) * (ulong)NumEntries);
ss.Empty();
ss.SetInitialMin(seq);
ss.Insert(seq);
ss.Root.ShouldNotBeNull();
ss.Root!.Base.ShouldBe(seq);
}
}
[Fact]
public void SeqSetDistinctUnion_ShouldSucceed()
{
var ss1 = new SequenceSet();
var seqs1 = new ulong[] { 1, 10, 100, 200 };
foreach (var seq in seqs1) ss1.Insert(seq);
var ss2 = new SequenceSet();
var seqs2 = new ulong[] { 5000, 6100, 6200, 6222 };
foreach (var seq in seqs2) ss2.Insert(seq);
var ss = ss1.Clone();
ss.Union(ss2);
var allSeqs = new List<ulong>(seqs1);
allSeqs.AddRange(seqs2);
ss.Size.ShouldBe(allSeqs.Count);
foreach (var seq in allSeqs)
ss.Exists(seq).ShouldBeTrue();
}
[Fact]
public void SeqSetDecodeV1_ShouldSucceed()
{
var seqs = new ulong[] { 22, 222, 2222, 222_222, 2_222_222 };
var encStr = @"FgEDAAAABQAAAABgAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAADgIQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAA==";
var enc = Convert.FromBase64String(encStr);
var (ss, _) = SequenceSet.Decode(enc);
ss.Size.ShouldBe(seqs.Length);
foreach (var seq in seqs)
ss.Exists(seq).ShouldBeTrue();
}
// --- Encode/Decode round-trip ---
[Fact]
public void SeqSetEncodeDecode_RoundTrip_ShouldSucceed()
{
var num = 2_500_000;
var maxVal = 5_000_000;
var rng = new Random(42);
var reference = new HashSet<ulong>(num);
var ss = new SequenceSet();
for (var i = 0; i < num; i++)
{
var n = (ulong)rng.NextInt64(maxVal + 1);
ss.Insert(n);
reference.Add(n);
}
var buf = ss.Encode(null);
var (ss2, _) = SequenceSet.Decode(buf);
ss2.Nodes.ShouldBe(ss.Nodes);
ss2.Size.ShouldBe(ss.Size);
}
// --- Performance / scale tests (no strict timing assertions) ---
[Fact]
public void NoRaceSeqSetSizeComparison_ShouldSucceed()
{
// Insert 5M items; verify correctness (memory comparison is GC-managed, skip strict thresholds)
var num = 5_000_000;
var maxVal = 7_000_000;
var rng = new Random(42);
var ss = new SequenceSet();
var reference = new HashSet<ulong>(num);
for (var i = 0; i < num; i++)
{
var n = (ulong)rng.NextInt64(maxVal + 1);
ss.Insert(n);
reference.Add(n);
}
ss.Size.ShouldBe(reference.Count);
}
[Fact]
public void NoRaceSeqSetEncodeLarge_ShouldSucceed()
{
var num = 2_500_000;
var maxVal = 5_000_000;
var rng = new Random(42);
var ss = new SequenceSet();
for (var i = 0; i < num; i++)
ss.Insert((ulong)rng.NextInt64(maxVal + 1));
var buf = ss.Encode(null);
var (ss2, _) = SequenceSet.Decode(buf);
ss2.Nodes.ShouldBe(ss.Nodes);
ss2.Size.ShouldBe(ss.Size);
}
[Fact]
public void NoRaceSeqSetRelativeSpeed_ShouldSucceed()
{
// Correctness: all inserted items must be findable.
// Timing assertions are omitted — performance is validated via BenchmarkDotNet benchmarks.
var num = 1_000_000;
var maxVal = 3_000_000;
var rng = new Random(42);
var seqs = new ulong[num];
for (var i = 0; i < num; i++)
seqs[i] = (ulong)rng.NextInt64(maxVal + 1);
var ss = new SequenceSet();
foreach (var n in seqs) ss.Insert(n);
foreach (var n in seqs) ss.Exists(n).ShouldBeTrue();
}
}

View File

@@ -0,0 +1,948 @@
// Copyright 2023-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.
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server.Tests.Internal.DataStructures;
public class SubjectTreeTests
{
// Helper to convert string to byte array (Latin-1).
private static byte[] B(string s) => System.Text.Encoding.Latin1.GetBytes(s);
// Helper to count matches.
private static int MatchCount(SubjectTree<int> st, string filter)
{
var count = 0;
st.Match(B(filter), (_, _) =>
{
count++;
return true;
});
return count;
}
// -------------------------------------------------------------------------
// TestSubjectTreeBasics
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeBasics()
{
var st = new SubjectTree<int>();
st.Size().ShouldBe(0);
// Single leaf insert.
var (old, updated) = st.Insert(B("foo.bar.baz"), 22);
old.ShouldBe(default);
updated.ShouldBeFalse();
st.Size().ShouldBe(1);
// Find should not work with a wildcard.
var (_, found) = st.Find(B("foo.bar.*"));
found.ShouldBeFalse();
// Find with literal — single leaf.
var (val, found2) = st.Find(B("foo.bar.baz"));
found2.ShouldBeTrue();
val.ShouldBe(22);
// Update single leaf.
var (old2, updated2) = st.Insert(B("foo.bar.baz"), 33);
old2.ShouldBe(22);
updated2.ShouldBeTrue();
st.Size().ShouldBe(1);
// Split the tree.
var (old3, updated3) = st.Insert(B("foo.bar"), 22);
old3.ShouldBe(default);
updated3.ShouldBeFalse();
st.Size().ShouldBe(2);
// Find both entries after split.
var (v1, f1) = st.Find(B("foo.bar"));
f1.ShouldBeTrue();
v1.ShouldBe(22);
var (v2, f2) = st.Find(B("foo.bar.baz"));
f2.ShouldBeTrue();
v2.ShouldBe(33);
}
// -------------------------------------------------------------------------
// TestSubjectTreeConstruction
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeConstruction()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 1);
st.Insert(B("foo.bar.B"), 2);
st.Insert(B("foo.bar.C"), 3);
st.Insert(B("foo.baz.A"), 11);
st.Insert(B("foo.baz.B"), 22);
st.Insert(B("foo.baz.C"), 33);
st.Insert(B("foo.bar"), 42);
// Validate structure.
st._root.ShouldNotBeNull();
st._root!.Kind.ShouldBe("NODE4");
st._root.NumChildren.ShouldBe(2);
// Now delete "foo.bar" and verify structure collapses correctly.
var (v, found) = st.Delete(B("foo.bar"));
found.ShouldBeTrue();
v.ShouldBe(42);
// The remaining entries should still be findable.
var (v1, f1) = st.Find(B("foo.bar.A"));
f1.ShouldBeTrue();
v1.ShouldBe(1);
var (v2, f2) = st.Find(B("foo.bar.B"));
f2.ShouldBeTrue();
v2.ShouldBe(2);
var (v3, f3) = st.Find(B("foo.bar.C"));
f3.ShouldBeTrue();
v3.ShouldBe(3);
var (v4, f4) = st.Find(B("foo.baz.A"));
f4.ShouldBeTrue();
v4.ShouldBe(11);
}
// -------------------------------------------------------------------------
// TestSubjectTreeNodeGrow
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeNodeGrow()
{
var st = new SubjectTree<int>();
// Fill a node4 (4 children).
for (var i = 0; i < 4; i++)
{
var subj = B($"foo.bar.{(char)('A' + i)}");
var (old, upd) = st.Insert(subj, 22);
old.ShouldBe(default);
upd.ShouldBeFalse();
}
st._root.ShouldBeOfType<SubjectTreeNode4<int>>();
// 5th child causes grow to node10.
st.Insert(B("foo.bar.E"), 22);
st._root.ShouldBeOfType<SubjectTreeNode10<int>>();
// Fill to 10.
for (var i = 5; i < 10; i++)
{
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
}
// 11th child causes grow to node16.
st.Insert(B("foo.bar.K"), 22);
st._root.ShouldBeOfType<SubjectTreeNode16<int>>();
// Fill to 16.
for (var i = 11; i < 16; i++)
{
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
}
// 17th child causes grow to node48.
st.Insert(B("foo.bar.Q"), 22);
st._root.ShouldBeOfType<SubjectTreeNode48<int>>();
// Fill the node48.
for (var i = 17; i < 48; i++)
{
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
}
// 49th child causes grow to node256.
var subjLast = B($"foo.bar.{(char)('A' + 49)}");
st.Insert(subjLast, 22);
st._root.ShouldBeOfType<SubjectTreeNode256<int>>();
}
// -------------------------------------------------------------------------
// TestSubjectTreeInsertSamePivot (same pivot bug)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeInsertSamePivot()
{
var testSubjects = new[]
{
B("0d00.2abbb82c1d.6e16.fa7f85470e.3e46"),
B("534b12.3486c17249.4dde0666"),
B("6f26aabd.920ee3.d4d3.5ffc69f6"),
B("8850.ade3b74c31.aa533f77.9f59.a4bd8415.b3ed7b4111"),
B("5a75047dcb.5548e845b6.76024a34.14d5b3.80c426.51db871c3a"),
B("825fa8acfc.5331.00caf8bbbd.107c4b.c291.126d1d010e"),
};
var st = new SubjectTree<int>();
foreach (var subj in testSubjects)
{
var (old, upd) = st.Insert(subj, 22);
old.ShouldBe(default);
upd.ShouldBeFalse();
var (_, found) = st.Find(subj);
found.ShouldBeTrue($"Could not find subject '{System.Text.Encoding.Latin1.GetString(subj)}' after insert");
}
}
// -------------------------------------------------------------------------
// TestSubjectTreeInsertLonger
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeInsertLonger()
{
var st = new SubjectTree<int>();
st.Insert(B("a1.aaaaaaaaaaaaaaaaaaaaaa0"), 1);
st.Insert(B("a2.0"), 2);
st.Insert(B("a1.aaaaaaaaaaaaaaaaaaaaaa1"), 3);
st.Insert(B("a2.1"), 4);
// Simulate purge of a2.>
st.Delete(B("a2.0"));
st.Delete(B("a2.1"));
st.Size().ShouldBe(2);
var (v1, f1) = st.Find(B("a1.aaaaaaaaaaaaaaaaaaaaaa0"));
f1.ShouldBeTrue();
v1.ShouldBe(1);
var (v2, f2) = st.Find(B("a1.aaaaaaaaaaaaaaaaaaaaaa1"));
f2.ShouldBeTrue();
v2.ShouldBe(3);
}
// -------------------------------------------------------------------------
// TestInsertEdgeCases
// -------------------------------------------------------------------------
[Fact]
public void TestInsertEdgeCases()
{
var st = new SubjectTree<int>();
// Reject subject with noPivot byte (127).
var (old, upd) = st.Insert(new byte[] { (byte)'f', (byte)'o', (byte)'o', 127 }, 1);
old.ShouldBe(default);
upd.ShouldBeFalse();
st.Size().ShouldBe(0);
// Empty-ish subjects.
st.Insert(B("a"), 1);
st.Insert(B("b"), 2);
st.Size().ShouldBe(2);
}
// -------------------------------------------------------------------------
// TestFindEdgeCases
// -------------------------------------------------------------------------
[Fact]
public void TestFindEdgeCases()
{
var st = new SubjectTree<int>();
var (_, found) = st.Find(B("anything"));
found.ShouldBeFalse();
st.Insert(B("foo"), 42);
var (v, f) = st.Find(B("foo"));
f.ShouldBeTrue();
v.ShouldBe(42);
var (_, f2) = st.Find(B("fo"));
f2.ShouldBeFalse();
var (_, f3) = st.Find(B("foobar"));
f3.ShouldBeFalse();
}
// -------------------------------------------------------------------------
// TestSubjectTreeNodeDelete
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeNodeDelete()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 22);
var (v, found) = st.Delete(B("foo.bar.A"));
found.ShouldBeTrue();
v.ShouldBe(22);
st._root.ShouldBeNull();
// Delete non-existent.
var (v2, found2) = st.Delete(B("foo.bar.A"));
found2.ShouldBeFalse();
v2.ShouldBe(default);
// Fill to node4 then shrink back through deletes.
st.Insert(B("foo.bar.A"), 11);
st.Insert(B("foo.bar.B"), 22);
st.Insert(B("foo.bar.C"), 33);
var (vC, fC) = st.Delete(B("foo.bar.C"));
fC.ShouldBeTrue();
vC.ShouldBe(33);
var (vB, fB) = st.Delete(B("foo.bar.B"));
fB.ShouldBeTrue();
vB.ShouldBe(22);
// Should have shrunk to a leaf.
st._root.ShouldNotBeNull();
st._root!.IsLeaf.ShouldBeTrue();
var (vA, fA) = st.Delete(B("foo.bar.A"));
fA.ShouldBeTrue();
vA.ShouldBe(11);
st._root.ShouldBeNull();
// Pop up to node10 and shrink back.
for (var i = 0; i < 5; i++)
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
st._root.ShouldBeOfType<SubjectTreeNode10<int>>();
var (vDel, fDel) = st.Delete(B("foo.bar.A"));
fDel.ShouldBeTrue();
vDel.ShouldBe(22);
st._root.ShouldBeOfType<SubjectTreeNode4<int>>();
// Pop up to node16 and shrink back.
for (var i = 0; i < 11; i++)
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
st._root.ShouldBeOfType<SubjectTreeNode16<int>>();
var (vDel2, fDel2) = st.Delete(B("foo.bar.A"));
fDel2.ShouldBeTrue();
vDel2.ShouldBe(22);
st._root.ShouldBeOfType<SubjectTreeNode10<int>>();
// Pop up to node48 and shrink back.
st = new SubjectTree<int>();
for (var i = 0; i < 17; i++)
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
st._root.ShouldBeOfType<SubjectTreeNode48<int>>();
var (vDel3, fDel3) = st.Delete(B("foo.bar.A"));
fDel3.ShouldBeTrue();
vDel3.ShouldBe(22);
st._root.ShouldBeOfType<SubjectTreeNode16<int>>();
// Pop up to node256 and shrink back.
st = new SubjectTree<int>();
for (var i = 0; i < 49; i++)
st.Insert(B($"foo.bar.{(char)('A' + i)}"), 22);
st._root.ShouldBeOfType<SubjectTreeNode256<int>>();
var (vDel4, fDel4) = st.Delete(B("foo.bar.A"));
fDel4.ShouldBeTrue();
vDel4.ShouldBe(22);
st._root.ShouldBeOfType<SubjectTreeNode48<int>>();
}
// -------------------------------------------------------------------------
// TestDeleteEdgeCases
// -------------------------------------------------------------------------
[Fact]
public void TestDeleteEdgeCases()
{
var st = new SubjectTree<int>();
// Delete from empty tree.
var (v, f) = st.Delete(B("foo"));
f.ShouldBeFalse();
v.ShouldBe(default);
// Insert and delete the only item.
st.Insert(B("foo"), 1);
var (v2, f2) = st.Delete(B("foo"));
f2.ShouldBeTrue();
v2.ShouldBe(1);
st.Size().ShouldBe(0);
st._root.ShouldBeNull();
// Delete a non-existent item in a non-empty tree.
st.Insert(B("bar"), 2);
var (v3, f3) = st.Delete(B("baz"));
f3.ShouldBeFalse();
v3.ShouldBe(default);
st.Size().ShouldBe(1);
}
// -------------------------------------------------------------------------
// TestSubjectTreeMatchLeafOnly
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeMatchLeafOnly()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.baz.A"), 1);
// All positions of pwc.
MatchCount(st, "foo.bar.*.A").ShouldBe(1);
MatchCount(st, "foo.*.baz.A").ShouldBe(1);
MatchCount(st, "foo.*.*.A").ShouldBe(1);
MatchCount(st, "foo.*.*.*").ShouldBe(1);
MatchCount(st, "*.*.*.*").ShouldBe(1);
// fwc tests.
MatchCount(st, ">").ShouldBe(1);
MatchCount(st, "foo.>").ShouldBe(1);
MatchCount(st, "foo.*.>").ShouldBe(1);
MatchCount(st, "foo.bar.>").ShouldBe(1);
MatchCount(st, "foo.bar.*.>").ShouldBe(1);
// Partial match should not trigger.
MatchCount(st, "foo.bar.baz").ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestSubjectTreeMatchNodes
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeMatchNodes()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 1);
st.Insert(B("foo.bar.B"), 2);
st.Insert(B("foo.bar.C"), 3);
st.Insert(B("foo.baz.A"), 11);
st.Insert(B("foo.baz.B"), 22);
st.Insert(B("foo.baz.C"), 33);
// Literals.
MatchCount(st, "foo.bar.A").ShouldBe(1);
MatchCount(st, "foo.baz.A").ShouldBe(1);
MatchCount(st, "foo.bar").ShouldBe(0);
// Internal pwc.
MatchCount(st, "foo.*.A").ShouldBe(2);
// Terminal pwc.
MatchCount(st, "foo.bar.*").ShouldBe(3);
MatchCount(st, "foo.baz.*").ShouldBe(3);
// fwc.
MatchCount(st, ">").ShouldBe(6);
MatchCount(st, "foo.>").ShouldBe(6);
MatchCount(st, "foo.bar.>").ShouldBe(3);
MatchCount(st, "foo.baz.>").ShouldBe(3);
// No false positives on prefix.
MatchCount(st, "foo.ba").ShouldBe(0);
// Add "foo.bar" and re-test.
st.Insert(B("foo.bar"), 42);
MatchCount(st, "foo.bar.A").ShouldBe(1);
MatchCount(st, "foo.bar").ShouldBe(1);
MatchCount(st, "foo.*.A").ShouldBe(2);
MatchCount(st, "foo.bar.*").ShouldBe(3);
MatchCount(st, ">").ShouldBe(7);
MatchCount(st, "foo.>").ShouldBe(7);
MatchCount(st, "foo.bar.>").ShouldBe(3);
MatchCount(st, "foo.baz.>").ShouldBe(3);
}
// -------------------------------------------------------------------------
// TestSubjectTreePartialTermination (partial termination)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreePartialTermination()
{
var st = new SubjectTree<int>();
st.Insert(B("STATE.GLOBAL.CELL1.7PDSGAALXNN000010.PROPERTY-A"), 5);
st.Insert(B("STATE.GLOBAL.CELL1.7PDSGAALXNN000010.PROPERTY-B"), 1);
st.Insert(B("STATE.GLOBAL.CELL1.7PDSGAALXNN000010.PROPERTY-C"), 2);
MatchCount(st, "STATE.GLOBAL.CELL1.7PDSGAALXNN000010.*").ShouldBe(3);
}
// -------------------------------------------------------------------------
// TestSubjectTreeMatchMultiple
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeMatchMultiple()
{
var st = new SubjectTree<int>();
st.Insert(B("A.B.C.D.0.G.H.I.0"), 22);
st.Insert(B("A.B.C.D.1.G.H.I.0"), 22);
MatchCount(st, "A.B.*.D.1.*.*.I.0").ShouldBe(1);
}
// -------------------------------------------------------------------------
// TestSubjectTreeMatchSubject (verify correct subject bytes in callback)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeMatchSubject()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 1);
st.Insert(B("foo.bar.B"), 2);
st.Insert(B("foo.bar.C"), 3);
st.Insert(B("foo.baz.A"), 11);
st.Insert(B("foo.baz.B"), 22);
st.Insert(B("foo.baz.C"), 33);
st.Insert(B("foo.bar"), 42);
var checkValMap = new Dictionary<string, int>
{
["foo.bar.A"] = 1,
["foo.bar.B"] = 2,
["foo.bar.C"] = 3,
["foo.baz.A"] = 11,
["foo.baz.B"] = 22,
["foo.baz.C"] = 33,
["foo.bar"] = 42,
};
st.Match(B(">"), (subject, val) =>
{
var subjectStr = System.Text.Encoding.Latin1.GetString(subject);
checkValMap.ShouldContainKey(subjectStr);
val.ShouldBe(checkValMap[subjectStr]);
return true;
});
}
// -------------------------------------------------------------------------
// TestMatchEdgeCases
// -------------------------------------------------------------------------
[Fact]
public void TestMatchEdgeCases()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.123"), 22);
st.Insert(B("one.two.three.four.five"), 22);
// Basic fwc.
MatchCount(st, ">").ShouldBe(2);
// No matches.
MatchCount(st, "invalid.>").ShouldBe(0);
// fwc after content is not terminal — should not match.
MatchCount(st, "foo.>.bar").ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestSubjectTreeIterOrdered
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeIterOrdered()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 1);
st.Insert(B("foo.bar.B"), 2);
st.Insert(B("foo.bar.C"), 3);
st.Insert(B("foo.baz.A"), 11);
st.Insert(B("foo.baz.B"), 22);
st.Insert(B("foo.baz.C"), 33);
st.Insert(B("foo.bar"), 42);
var checkValMap = new Dictionary<string, int>
{
["foo.bar"] = 42,
["foo.bar.A"] = 1,
["foo.bar.B"] = 2,
["foo.bar.C"] = 3,
["foo.baz.A"] = 11,
["foo.baz.B"] = 22,
["foo.baz.C"] = 33,
};
var checkOrder = new[]
{
"foo.bar",
"foo.bar.A",
"foo.bar.B",
"foo.bar.C",
"foo.baz.A",
"foo.baz.B",
"foo.baz.C",
};
var received = new List<string>();
st.IterOrdered((subject, val) =>
{
var subjectStr = System.Text.Encoding.Latin1.GetString(subject);
received.Add(subjectStr);
val.ShouldBe(checkValMap[subjectStr]);
return true;
});
received.Count.ShouldBe(checkOrder.Length);
for (var i = 0; i < checkOrder.Length; i++)
received[i].ShouldBe(checkOrder[i]);
// Make sure we can terminate early.
var count = 0;
st.IterOrdered((_, _) =>
{
count++;
return count != 4;
});
count.ShouldBe(4);
}
// -------------------------------------------------------------------------
// TestSubjectTreeIterFast
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeIterFast()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 1);
st.Insert(B("foo.bar.B"), 2);
st.Insert(B("foo.bar.C"), 3);
st.Insert(B("foo.baz.A"), 11);
st.Insert(B("foo.baz.B"), 22);
st.Insert(B("foo.baz.C"), 33);
st.Insert(B("foo.bar"), 42);
var checkValMap = new Dictionary<string, int>
{
["foo.bar.A"] = 1,
["foo.bar.B"] = 2,
["foo.bar.C"] = 3,
["foo.baz.A"] = 11,
["foo.baz.B"] = 22,
["foo.baz.C"] = 33,
["foo.bar"] = 42,
};
var received = 0;
st.IterFast((subject, val) =>
{
received++;
var subjectStr = System.Text.Encoding.Latin1.GetString(subject);
checkValMap.ShouldContainKey(subjectStr);
val.ShouldBe(checkValMap[subjectStr]);
return true;
});
received.ShouldBe(checkValMap.Count);
// Early termination.
received = 0;
st.IterFast((_, _) =>
{
received++;
return received != 4;
});
received.ShouldBe(4);
}
// -------------------------------------------------------------------------
// TestSubjectTreeEmpty
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeEmpty()
{
var st = new SubjectTree<int>();
st.Empty().ShouldBeTrue();
st.Insert(B("foo"), 1);
st.Empty().ShouldBeFalse();
st.Delete(B("foo"));
st.Empty().ShouldBeTrue();
}
// -------------------------------------------------------------------------
// TestSizeOnEmptyTree
// -------------------------------------------------------------------------
[Fact]
public void TestSizeOnEmptyTree()
{
var st = new SubjectTree<int>();
st.Size().ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestSubjectTreeNilNoPanic (nil/null safety)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeNullNoPanic()
{
var st = new SubjectTree<int>();
// Operations on empty tree should not throw.
st.Size().ShouldBe(0);
st.Empty().ShouldBeTrue();
var (_, f1) = st.Find(B("foo"));
f1.ShouldBeFalse();
var (_, f2) = st.Delete(B("foo"));
f2.ShouldBeFalse();
// Match on empty tree.
var count = 0;
st.Match(B(">"), (_, _) => { count++; return true; });
count.ShouldBe(0);
// MatchUntil on empty tree.
var completed = st.MatchUntil(B(">"), (_, _) => { count++; return true; });
completed.ShouldBeTrue();
// Iter on empty tree.
st.IterOrdered((_, _) => { count++; return true; });
st.IterFast((_, _) => { count++; return true; });
count.ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestSubjectTreeMatchUntil
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeMatchUntil()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 1);
st.Insert(B("foo.bar.B"), 2);
st.Insert(B("foo.bar.C"), 3);
st.Insert(B("foo.baz.A"), 11);
st.Insert(B("foo.baz.B"), 22);
st.Insert(B("foo.baz.C"), 33);
st.Insert(B("foo.bar"), 42);
// Early stop terminates traversal.
var n = 0;
var completed = st.MatchUntil(B("foo.>"), (_, _) =>
{
n++;
return n < 3;
});
n.ShouldBe(3);
completed.ShouldBeFalse();
// Match that completes normally.
n = 0;
completed = st.MatchUntil(B("foo.bar"), (_, _) =>
{
n++;
return true;
});
n.ShouldBe(1);
completed.ShouldBeTrue();
// Stop after 4 (more than available in "foo.baz.*").
n = 0;
completed = st.MatchUntil(B("foo.baz.*"), (_, _) =>
{
n++;
return n < 4;
});
n.ShouldBe(3);
completed.ShouldBeTrue();
}
// -------------------------------------------------------------------------
// TestSubjectTreeGSLIntersect (basic lazy intersect equivalent)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeLazyIntersect()
{
// Build two trees and verify that inserting matching keys from both yields correct count.
var tl = new SubjectTree<int>();
var tr = new SubjectTree<int>();
tl.Insert(B("foo.bar"), 1);
tl.Insert(B("foo.baz"), 2);
tl.Insert(B("other"), 3);
tr.Insert(B("foo.bar"), 10);
tr.Insert(B("foo.baz"), 20);
// Manually intersect: iterate smaller tree, find in larger.
var matches = new List<(string key, int vl, int vr)>();
tl.IterFast((key, vl) =>
{
var (vr, found) = tr.Find(key);
if (found)
matches.Add((System.Text.Encoding.Latin1.GetString(key), vl, vr));
return true;
});
matches.Count.ShouldBe(2);
}
// -------------------------------------------------------------------------
// TestSubjectTreePrefixMismatch
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreePrefixMismatch()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.bar.A"), 11);
st.Insert(B("foo.bar.B"), 22);
st.Insert(B("foo.bar.C"), 33);
// This will force a split.
st.Insert(B("foo.foo.A"), 44);
var (v1, f1) = st.Find(B("foo.bar.A"));
f1.ShouldBeTrue();
v1.ShouldBe(11);
var (v2, f2) = st.Find(B("foo.bar.B"));
f2.ShouldBeTrue();
v2.ShouldBe(22);
var (v3, f3) = st.Find(B("foo.bar.C"));
f3.ShouldBeTrue();
v3.ShouldBe(33);
var (v4, f4) = st.Find(B("foo.foo.A"));
f4.ShouldBeTrue();
v4.ShouldBe(44);
}
// -------------------------------------------------------------------------
// TestSubjectTreeNodesAndPaths
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeNodesAndPaths()
{
var st = new SubjectTree<int>();
void Check(string subj)
{
var (val, found) = st.Find(B(subj));
found.ShouldBeTrue();
val.ShouldBe(22);
}
st.Insert(B("foo.bar.A"), 22);
st.Insert(B("foo.bar.B"), 22);
st.Insert(B("foo.bar.C"), 22);
st.Insert(B("foo.bar"), 22);
Check("foo.bar.A");
Check("foo.bar.B");
Check("foo.bar.C");
Check("foo.bar");
// Deletion that involves shrinking / prefix adjustment.
st.Delete(B("foo.bar"));
Check("foo.bar.A");
Check("foo.bar.B");
Check("foo.bar.C");
}
// -------------------------------------------------------------------------
// TestSubjectTreeRandomTrack (basic random insert/find)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeRandomTrack()
{
var st = new SubjectTree<int>();
var tracked = new Dictionary<string, bool>();
var rng = new Random(42);
for (var i = 0; i < 200; i++)
{
var tokens = rng.Next(1, 5);
var parts = new List<string>();
for (var t = 0; t < tokens; t++)
{
var len = rng.Next(2, 7);
var chars = new char[len];
for (var c = 0; c < len; c++)
chars[c] = (char)('a' + rng.Next(26));
parts.Add(new string(chars));
}
var subj = string.Join(".", parts);
if (tracked.ContainsKey(subj)) continue;
tracked[subj] = true;
st.Insert(B(subj), 1);
}
foreach (var subj in tracked.Keys)
{
var (_, found) = st.Find(B(subj));
found.ShouldBeTrue($"Subject '{subj}' not found after insert");
}
st.Size().ShouldBe(tracked.Count);
}
// -------------------------------------------------------------------------
// TestSubjectTreeNode48 (detailed node48 operations)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeNode48Operations()
{
var st = new SubjectTree<int>();
// Insert 26 single-char subjects (no prefix — goes directly to node48).
for (var i = 0; i < 26; i++)
st.Insert(new[] { (byte)('A' + i) }, 22);
st._root.ShouldBeOfType<SubjectTreeNode48<int>>();
st._root!.NumChildren.ShouldBe(26);
st.Delete(new[] { (byte)'B' });
st._root.NumChildren.ShouldBe(25);
st.Delete(new[] { (byte)'Z' });
st._root.NumChildren.ShouldBe(24);
// Remaining subjects should still be findable.
for (var i = 0; i < 26; i++)
{
var ch = (byte)('A' + i);
if (ch == (byte)'B' || ch == (byte)'Z') continue;
var (_, found) = st.Find(new[] { ch });
found.ShouldBeTrue();
}
}
// -------------------------------------------------------------------------
// TestSubjectTreeMatchTsepSecondThenPartial (bug regression)
// -------------------------------------------------------------------------
[Fact]
public void TestSubjectTreeMatchTsepSecondThenPartial()
{
var st = new SubjectTree<int>();
st.Insert(B("foo.xxxxx.foo1234.zz"), 22);
st.Insert(B("foo.yyy.foo123.zz"), 22);
st.Insert(B("foo.yyybar789.zz"), 22);
st.Insert(B("foo.yyy.foo12345.zz"), 22);
st.Insert(B("foo.yyy.foo12345.yy"), 22);
st.Insert(B("foo.yyy.foo123456789.zz"), 22);
MatchCount(st, "foo.*.foo123456789.*").ShouldBe(1);
MatchCount(st, "foo.*.*.zzz.foo.>").ShouldBe(0);
}
}

View File

@@ -0,0 +1,316 @@
// Copyright 2021-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.
using System.Collections.Concurrent;
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
/// <summary>
/// Tests for <see cref="IpQueue{T}"/>.
/// Mirrors server/ipqueue_test.go:
/// TestIPQueueBasic (ID 688), TestIPQueuePush (ID 689), TestIPQueuePop (ID 690),
/// TestIPQueuePopOne (ID 691), TestIPQueueMultiProducers (ID 692),
/// TestIPQueueRecycle (ID 693), TestIPQueueDrain (ID 694),
/// TestIPQueueSizeCalculation (ID 695), TestIPQueueSizeCalculationWithLimits (ID 696).
/// Benchmarks (IDs 697715) are n/a.
/// </summary>
public sealed class IpQueueTests
{
[Fact]
public void Basic_ShouldInitialiseCorrectly()
{
// Mirror: TestIPQueueBasic
var registry = new ConcurrentDictionary<string, object>();
var q = new IpQueue<int>("test", registry);
q.MaxRecycleSize.ShouldBe(IpQueue<int>.DefaultMaxRecycleSize);
q.Ch.TryRead(out _).ShouldBeFalse("channel should be empty on creation");
q.Len().ShouldBe(0);
// Create a second queue with custom max recycle size.
var q2 = new IpQueue<int>("test2", registry, maxRecycleSize: 10);
q2.MaxRecycleSize.ShouldBe(10);
// Both should be in the registry.
registry.ContainsKey("test").ShouldBeTrue();
registry.ContainsKey("test2").ShouldBeTrue();
// Unregister both.
q.Unregister();
q2.Unregister();
registry.IsEmpty.ShouldBeTrue("registry should be empty after unregister");
// Push/pop should still work after unregister.
q.Push(1);
var elts = q.Pop();
elts.ShouldNotBeNull();
elts!.Length.ShouldBe(1);
q2.Push(2);
var (e, ok) = q2.PopOne();
ok.ShouldBeTrue();
e.ShouldBe(2);
}
[Fact]
public void Push_ShouldNotifyOnFirstElement()
{
// Mirror: TestIPQueuePush
var q = new IpQueue<int>("test");
q.Push(1);
q.Len().ShouldBe(1);
q.Ch.TryRead(out _).ShouldBeTrue("should have been notified after first push");
// Second push should NOT send another notification.
q.Push(2);
q.Len().ShouldBe(2);
q.Ch.TryRead(out _).ShouldBeFalse("should not notify again when queue was not empty");
}
[Fact]
public void Pop_ShouldReturnElementsAndTrackInProgress()
{
// Mirror: TestIPQueuePop
var q = new IpQueue<int>("test");
q.Push(1);
q.Ch.TryRead(out _); // consume signal
var elts = q.Pop();
elts.ShouldNotBeNull();
elts!.Length.ShouldBe(1);
q.Len().ShouldBe(0);
// Channel should still be empty after pop.
q.Ch.TryRead(out _).ShouldBeFalse();
// InProgress should be 1 — pop increments it.
q.InProgress().ShouldBe(1L);
// Recycle decrements it.
q.Recycle(elts);
q.InProgress().ShouldBe(0L);
// Pop on empty queue returns null.
var empty = q.Pop();
empty.ShouldBeNull();
q.InProgress().ShouldBe(0L);
}
[Fact]
public void PopOne_ShouldReturnOneAtATime()
{
// Mirror: TestIPQueuePopOne
var q = new IpQueue<int>("test");
q.Push(1);
q.Ch.TryRead(out _); // consume signal
var (e, ok) = q.PopOne();
ok.ShouldBeTrue();
e.ShouldBe(1);
q.Len().ShouldBe(0);
q.InProgress().ShouldBe(0L, "popOne does not increment inprogress");
q.Ch.TryRead(out _).ShouldBeFalse("no notification when queue is emptied by popOne");
q.Push(2);
q.Push(3);
var (e2, ok2) = q.PopOne();
ok2.ShouldBeTrue();
e2.ShouldBe(2);
q.Len().ShouldBe(1);
q.Ch.TryRead(out _).ShouldBeTrue("should re-notify when more items remain");
var (e3, ok3) = q.PopOne();
ok3.ShouldBeTrue();
e3.ShouldBe(3);
q.Len().ShouldBe(0);
q.Ch.TryRead(out _).ShouldBeFalse("no notification after last element removed");
var (_, okEmpty) = q.PopOne();
okEmpty.ShouldBeFalse("popOne on empty queue returns false");
}
[Fact]
public async Task MultiProducers_ShouldReceiveAllElements()
{
// Mirror: TestIPQueueMultiProducers
var q = new IpQueue<int>("test");
const int itemsPerProducer = 100;
const int numProducers = 3;
var tasks = Enumerable.Range(0, numProducers).Select(p =>
Task.Run(() =>
{
for (var i = p * itemsPerProducer + 1; i <= (p + 1) * itemsPerProducer; i++)
q.Push(i);
})).ToArray();
var received = new HashSet<int>();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (received.Count < numProducers * itemsPerProducer &&
!cts.Token.IsCancellationRequested)
{
if (q.Ch.TryRead(out _))
{
var batch = q.Pop();
if (batch != null)
{
foreach (var v in batch) received.Add(v);
q.Recycle(batch);
q.InProgress().ShouldBe(0L);
}
}
else
{
await Task.Delay(1, cts.Token);
}
}
await Task.WhenAll(tasks);
received.Count.ShouldBe(numProducers * itemsPerProducer, "all elements should be received");
}
[Fact]
public void Recycle_ShouldDecrementInProgressAndAllowReuse()
{
// Mirror: TestIPQueueRecycle (behavioral aspects)
var q = new IpQueue<int>("test");
const int total = 1000;
for (var i = 0; i < total; i++)
{
var (len, err) = q.Push(i);
err.ShouldBeNull();
len.ShouldBe(i + 1);
}
var values = q.Pop();
values.ShouldNotBeNull();
values!.Length.ShouldBe(total);
q.InProgress().ShouldBe((long)total);
q.Recycle(values);
q.InProgress().ShouldBe(0L, "recycle should decrement inprogress");
// Should be able to push/pop again after recycle.
var (l, err2) = q.Push(1001);
err2.ShouldBeNull();
l.ShouldBe(1);
var values2 = q.Pop();
values2.ShouldNotBeNull();
values2!.Length.ShouldBe(1);
values2[0].ShouldBe(1001);
// Recycle with small max recycle size: large arrays should not be pooled
// (behavioral: push/pop still works correctly).
var q2 = new IpQueue<int>("test2", maxRecycleSize: 10);
for (var i = 0; i < 100; i++) q2.Push(i);
var bigBatch = q2.Pop();
bigBatch.ShouldNotBeNull();
bigBatch!.Length.ShouldBe(100);
q2.Recycle(bigBatch);
q2.InProgress().ShouldBe(0L);
q2.Push(1001);
var small = q2.Pop();
small.ShouldNotBeNull();
small!.Length.ShouldBe(1);
q2.Recycle(small);
}
[Fact]
public void Drain_ShouldEmptyQueueAndConsumeSignal()
{
// Mirror: TestIPQueueDrain
var q = new IpQueue<int>("test");
for (var i = 1; i <= 100; i++) q.Push(i);
var drained = q.Drain();
drained.ShouldBe(100);
// Signal should have been consumed.
q.Ch.TryRead(out _).ShouldBeFalse("drain should consume the notification signal");
q.Len().ShouldBe(0);
}
[Fact]
public void SizeCalculation_ShouldTrackTotalSize()
{
// Mirror: TestIPQueueSizeCalculation
const int elemSize = 16;
var q = new IpQueue<byte[]>("test", sizeCalc: e => (ulong)e.Length);
for (var i = 0; i < 10; i++)
{
q.Push(new byte[elemSize]);
q.Len().ShouldBe(i + 1);
q.Size().ShouldBe((ulong)(i + 1) * elemSize);
}
for (var i = 10; i > 5; i--)
{
q.PopOne();
q.Len().ShouldBe(i - 1);
q.Size().ShouldBe((ulong)(i - 1) * elemSize);
}
q.Pop();
q.Len().ShouldBe(0);
q.Size().ShouldBe(0UL);
}
[Fact]
public void SizeCalculationWithLimits_ShouldEnforceLimits()
{
// Mirror: TestIPQueueSizeCalculationWithLimits
const int elemSize = 16;
Func<byte[], ulong> calc = e => (ulong)e.Length;
var elem = new byte[elemSize];
// LimitByLen
var q1 = new IpQueue<byte[]>("test-len", sizeCalc: calc, maxLen: 5);
for (var i = 0; i < 10; i++)
{
var (n, err) = q1.Push(elem);
if (i >= 5)
{
err.ShouldBeSameAs(IpQueueErrors.LenLimitReached, $"iteration {i}");
}
else
{
err.ShouldBeNull($"iteration {i}");
}
n.ShouldBeLessThan(6);
}
// LimitBySize
var q2 = new IpQueue<byte[]>("test-size", sizeCalc: calc, maxSize: elemSize * 5);
for (var i = 0; i < 10; i++)
{
var (n, err) = q2.Push(elem);
if (i >= 5)
{
err.ShouldBeSameAs(IpQueueErrors.SizeLimitReached, $"iteration {i}");
}
else
{
err.ShouldBeNull($"iteration {i}");
}
n.ShouldBeLessThan(6);
}
}
}

View File

@@ -0,0 +1,131 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
/// <summary>
/// Tests for NatsLogger / ServerLogging — mirrors tests from server/log_test.go.
/// </summary>
public class NatsLoggerTests
{
private sealed class TestLogger : INatsLogger
{
public List<string> Messages { get; } = [];
public void Noticef(string format, params object[] args) => Messages.Add($"[INF] {string.Format(format, args)}");
public void Warnf(string format, params object[] args) => Messages.Add($"[WRN] {string.Format(format, args)}");
public void Fatalf(string format, params object[] args) => Messages.Add($"[FTL] {string.Format(format, args)}");
public void Errorf(string format, params object[] args) => Messages.Add($"[ERR] {string.Format(format, args)}");
public void Debugf(string format, params object[] args) => Messages.Add($"[DBG] {string.Format(format, args)}");
public void Tracef(string format, params object[] args) => Messages.Add($"[TRC] {string.Format(format, args)}");
}
/// <summary>
/// Mirrors TestSetLogger — verify logger assignment and atomic flags.
/// </summary>
[Fact] // T:2017
public void SetLogger_ShouldSetLoggerAndFlags()
{
var logging = new ServerLogging();
var testLog = new TestLogger();
logging.SetLoggerV2(testLog, true, true, false);
logging.IsDebug.ShouldBeTrue();
logging.IsTrace.ShouldBeTrue();
logging.IsTraceSysAcc.ShouldBeFalse();
logging.GetLogger().ShouldBe(testLog);
}
/// <summary>
/// Verify all log methods produce output when flags enabled.
/// </summary>
[Fact] // T:2017 (continuation)
public void AllLogMethods_ShouldProduceOutput()
{
var logging = new ServerLogging();
var testLog = new TestLogger();
logging.SetLoggerV2(testLog, true, true, false);
logging.Noticef("notice {0}", "test");
logging.Errorf("error {0}", "test");
logging.Warnf("warn {0}", "test");
logging.Fatalf("fatal {0}", "test");
logging.Debugf("debug {0}", "test");
logging.Tracef("trace {0}", "test");
testLog.Messages.Count.ShouldBe(6);
testLog.Messages[0].ShouldContain("[INF]");
testLog.Messages[1].ShouldContain("[ERR]");
testLog.Messages[4].ShouldContain("[DBG]");
testLog.Messages[5].ShouldContain("[TRC]");
}
/// <summary>
/// Debug/Trace should not produce output when flags disabled.
/// </summary>
[Fact] // T:2017 (continuation)
public void DebugTrace_ShouldBeNoOpWhenDisabled()
{
var logging = new ServerLogging();
var testLog = new TestLogger();
logging.SetLoggerV2(testLog, false, false, false);
logging.Debugf("debug");
logging.Tracef("trace");
testLog.Messages.ShouldBeEmpty();
}
/// <summary>
/// Verify null logger does not throw.
/// </summary>
[Fact]
public void NullLogger_ShouldNotThrow()
{
var logging = new ServerLogging();
Should.NotThrow(() => logging.Noticef("test"));
Should.NotThrow(() => logging.Errorf("test"));
Should.NotThrow(() => logging.Debugf("test"));
}
/// <summary>
/// Verify rate-limited logging suppresses duplicate messages.
/// </summary>
[Fact] // T:2017 (RateLimitWarnf behavior)
public void RateLimitWarnf_ShouldSuppressDuplicates()
{
var logging = new ServerLogging();
var testLog = new TestLogger();
logging.SetLoggerV2(testLog, false, false, false);
logging.RateLimitWarnf("duplicate message");
logging.RateLimitWarnf("duplicate message");
logging.RateLimitWarnf("different message");
// Should only log 2 unique messages, not 3.
testLog.Messages.Count.ShouldBe(2);
}
/// <summary>
/// Verify Errors/Errorc/Errorsc convenience methods.
/// </summary>
[Fact]
public void ErrorVariants_ShouldFormatCorrectly()
{
var logging = new ServerLogging();
var testLog = new TestLogger();
logging.SetLoggerV2(testLog, false, false, false);
logging.Errors("client", new Exception("conn reset"));
logging.Errorc("TLS", new Exception("cert expired"));
logging.Errorsc("route", "cluster", new Exception("timeout"));
testLog.Messages.Count.ShouldBe(3);
testLog.Messages[0].ShouldContain("client - conn reset");
testLog.Messages[1].ShouldContain("TLS: cert expired");
testLog.Messages[2].ShouldContain("route - cluster: timeout");
}
}

View File

@@ -0,0 +1,56 @@
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
/// <summary>
/// Tests for <see cref="ProcessStatsProvider"/>, mirroring pse_test.go.
/// The Go tests compare against `ps` command output — the .NET tests verify
/// that values are within reasonable bounds since Process gives us the same data
/// through a managed API without needing external command comparison.
/// </summary>
public sealed class ProcessStatsProviderTests
{
[Fact]
public async Task PSEmulationCPU_ShouldReturnReasonableValue()
{
// Mirror: TestPSEmulationCPU
// Allow one sampling cycle to complete.
await Task.Delay(TimeSpan.FromSeconds(2));
ProcessStatsProvider.ProcUsage(out var pcpu, out _, out _);
// CPU % should be non-negative and at most 100% × processor count.
pcpu.ShouldBeGreaterThanOrEqualTo(0);
pcpu.ShouldBeLessThanOrEqualTo(100.0 * Environment.ProcessorCount);
}
[Fact]
public void PSEmulationMem_ShouldReturnReasonableValue()
{
// Mirror: TestPSEmulationMem
ProcessStatsProvider.ProcUsage(out _, out var rss, out var vss);
// RSS should be at least 1 MB (any .NET process uses far more).
rss.ShouldBeGreaterThan(1024L * 1024L);
// VSS should be at least as large as RSS.
vss.ShouldBeGreaterThanOrEqualTo(rss);
}
[Fact]
public async Task PSEmulationWin_ShouldCacheAndRefresh()
{
// Mirror: TestPSEmulationWin (caching behaviour validation)
ProcessStatsProvider.ProcUsage(out _, out var rss1, out _);
ProcessStatsProvider.ProcUsage(out _, out var rss2, out _);
// Two immediate calls should return the same cached value.
rss1.ShouldBe(rss2);
// After a sampling interval, values should still be valid.
await Task.Delay(TimeSpan.FromSeconds(2));
ProcessStatsProvider.ProcUsage(out _, out var rssAfter, out _);
rssAfter.ShouldBeGreaterThan(0);
}
}

View File

@@ -0,0 +1,47 @@
// Copyright 2021-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.
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
/// <summary>
/// Tests for <see cref="RateCounter"/>.
/// Mirrors server/rate_counter_test.go: TestRateCounter (ID 2720).
/// </summary>
public sealed class RateCounterTests
{
[Fact]
public async Task RateCounter_ShouldAllowUpToLimitThenBlockAndReset()
{
// Mirror: TestRateCounter
var counter = new RateCounter(10) { Interval = TimeSpan.FromMilliseconds(100) };
// First 10 calls should be allowed (counts 09 < limit 10).
for (var i = 0; i < 10; i++)
counter.Allow().ShouldBeTrue($"should allow on iteration {i}");
// Next 5 should be blocked.
for (var i = 0; i < 5; i++)
counter.Allow().ShouldBeFalse($"should not allow on iteration {i}");
// countBlocked returns and resets the blocked count.
counter.CountBlocked().ShouldBe(5UL);
counter.CountBlocked().ShouldBe(0UL);
// After the window expires, should allow again.
await Task.Delay(150);
counter.Allow().ShouldBeTrue("should allow after window expired");
}
}

View File

@@ -0,0 +1,194 @@
// 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.
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
/// <summary>
/// Tests for <see cref="ServerUtilities"/>.
/// Mirrors server/util_test.go: TestParseSize (ID 3061), TestParseSInt64 (ID 3062),
/// TestParseHostPort (ID 3063), TestURLsAreEqual (ID 3064), TestComma (ID 3065),
/// TestURLRedaction (ID 3066), TestVersionAtLeast (ID 3067).
/// Benchmarks (IDs 30683073) are n/a.
/// </summary>
public sealed class ServerUtilitiesTests
{
[Fact]
public void ParseSize_ShouldParseValidAndRejectInvalid()
{
// Mirror: TestParseSize
ServerUtilities.ParseSize(ReadOnlySpan<byte>.Empty).ShouldBe(-1, "nil/empty should return -1");
var n = "12345678"u8;
ServerUtilities.ParseSize(n).ShouldBe(12345678);
var bad = "12345invalid678"u8;
ServerUtilities.ParseSize(bad).ShouldBe(-1, "non-digit chars should return -1");
}
[Fact]
public void ParseInt64_ShouldParseValidAndRejectInvalid()
{
// Mirror: TestParseSInt64
ServerUtilities.ParseInt64(ReadOnlySpan<byte>.Empty).ShouldBe(-1L, "empty should return -1");
var n = "12345678"u8;
ServerUtilities.ParseInt64(n).ShouldBe(12345678L);
var bad = "12345invalid678"u8;
ServerUtilities.ParseInt64(bad).ShouldBe(-1L, "non-digit chars should return -1");
}
[Fact]
public void ParseHostPort_ShouldSplitCorrectly()
{
// Mirror: TestParseHostPort
void Check(string hostPort, int defaultPort, string expectedHost, int expectedPort, bool expectError)
{
var (host, port, err) = ServerUtilities.ParseHostPort(hostPort, defaultPort);
if (expectError)
{
err.ShouldNotBeNull($"expected error for hostPort={hostPort}");
return;
}
err.ShouldBeNull($"unexpected error for hostPort={hostPort}: {err?.Message}");
host.ShouldBe(expectedHost);
port.ShouldBe(expectedPort);
}
Check("addr:1234", 5678, "addr", 1234, false);
Check(" addr:1234 ", 5678, "addr", 1234, false);
Check(" addr : 1234 ", 5678, "addr", 1234, false);
Check("addr", 5678, "addr", 5678, false); // no port → default
Check(" addr ", 5678, "addr", 5678, false);
Check("addr:-1", 5678, "addr", 5678, false); // -1 → default
Check(" addr:-1 ", 5678, "addr", 5678, false);
Check(" addr : -1 ", 5678, "addr", 5678, false);
Check("addr:0", 5678, "addr", 5678, false); // 0 → default
Check(" addr:0 ", 5678, "addr", 5678, false);
Check(" addr : 0 ", 5678, "addr", 5678, false);
Check("addr:addr", 0, "", 0, true); // non-numeric port
Check("addr:::1234", 0, "", 0, true); // ambiguous colons
Check("", 0, "", 0, true); // empty
}
[Fact]
public void UrlsAreEqual_ShouldCompareCorrectly()
{
// Mirror: TestURLsAreEqual
void Check(string u1Str, string u2Str, bool expectedSame)
{
var u1 = new Uri(u1Str);
var u2 = new Uri(u2Str);
ServerUtilities.UrlsAreEqual(u1, u2).ShouldBe(expectedSame,
$"expected {u1Str} and {u2Str} to be {(expectedSame ? "equal" : "different")}");
}
Check("nats://localhost:4222", "nats://localhost:4222", true);
Check("nats://ivan:pwd@localhost:4222", "nats://ivan:pwd@localhost:4222", true);
Check("nats://ivan@localhost:4222", "nats://ivan@localhost:4222", true);
Check("nats://ivan:@localhost:4222", "nats://ivan:@localhost:4222", true);
Check("nats://host1:4222", "nats://host2:4222", false);
}
[Fact]
public void Comma_ShouldFormatWithThousandSeparators()
{
// Mirror: TestComma
var cases = new (long input, string expected)[]
{
(0, "0"),
(10, "10"),
(100, "100"),
(1_000, "1,000"),
(10_000, "10,000"),
(100_000, "100,000"),
(10_000_000, "10,000,000"),
(10_100_000, "10,100,000"),
(10_010_000, "10,010,000"),
(10_001_000, "10,001,000"),
(123_456_789, "123,456,789"),
(9_223_372_036_854_775_807L, "9,223,372,036,854,775,807"), // long.MaxValue
(long.MinValue, "-9,223,372,036,854,775,808"),
(-123_456_789, "-123,456,789"),
(-10_100_000, "-10,100,000"),
(-10_010_000, "-10,010,000"),
(-10_001_000, "-10,001,000"),
(-10_000_000, "-10,000,000"),
(-100_000, "-100,000"),
(-10_000, "-10,000"),
(-1_000, "-1,000"),
(-100, "-100"),
(-10, "-10"),
};
foreach (var (input, expected) in cases)
ServerUtilities.Comma(input).ShouldBe(expected, $"Comma({input})");
}
[Fact]
public void UrlRedaction_ShouldReplacePasswords()
{
// Mirror: TestURLRedaction
var cases = new (string full, string safe)[]
{
("nats://foo:bar@example.org", "nats://foo:xxxxx@example.org"),
("nats://foo@example.org", "nats://foo@example.org"),
("nats://example.org", "nats://example.org"),
("nats://example.org/foo?bar=1", "nats://example.org/foo?bar=1"),
};
var listFull = new Uri[cases.Length];
var listSafe = new Uri[cases.Length];
for (var i = 0; i < cases.Length; i++)
{
ServerUtilities.RedactUrlString(cases[i].full).ShouldBe(cases[i].safe,
$"RedactUrlString[{i}]");
listFull[i] = new Uri(cases[i].full);
listSafe[i] = new Uri(cases[i].safe);
}
var results = ServerUtilities.RedactUrlList(listFull);
for (var i = 0; i < results.Length; i++)
results[i].ToString().ShouldBe(listSafe[i].ToString(), $"RedactUrlList[{i}]");
}
[Fact]
public void VersionAtLeast_ShouldReturnCorrectResult()
{
// Mirror: TestVersionAtLeast
var cases = new (string version, int major, int minor, int update, bool result)[]
{
("2.0.0-beta", 1, 9, 9, true),
("2.0.0", 1, 99, 9, true),
("2.2.0", 2, 1, 9, true),
("2.2.2", 2, 2, 2, true),
("2.2.2", 2, 2, 3, false),
("2.2.2", 2, 3, 2, false),
("2.2.2", 3, 2, 2, false),
("2.22.2", 3, 0, 0, false),
("2.2.22", 2, 3, 0, false),
("bad.version",1, 2, 3, false),
};
foreach (var (version, major, minor, update, expected) in cases)
{
ServerUtilities.VersionAtLeast(version, major, minor, update)
.ShouldBe(expected,
$"VersionAtLeast({version}, {major}, {minor}, {update})");
}
}
}

Some files were not shown because too many files have changed in this diff Show More