Compare commits
10 Commits
023a5ddb7e
...
11b387e442
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11b387e442 | ||
|
|
88b1391ef0 | ||
|
|
0a54d342ba | ||
|
|
ed78a100e2 | ||
|
|
b8f2f66d45 | ||
|
|
f08fc5d6a7 | ||
|
|
11c0b92fbd | ||
|
|
8050ee1897 | ||
|
|
66628bc25a | ||
|
|
b335230498 |
741
docs/plans/2026-02-26-phase-6-porting.md
Normal file
741
docs/plans/2026-02-26-phase-6-porting.md
Normal 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 5–40) | **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 1–4 | **Tests:** IDs 1–3 | **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"
|
||||
```
|
||||
36
docs/plans/2026-02-26-phase-6-porting.md.tasks.json
Normal file
36
docs/plans/2026-02-26-phase-6-porting.md.tasks.json
Normal 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"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
143
docs/plans/phases/phase6sessions/readme.md
Normal file
143
docs/plans/phases/phase6sessions/readme.md
Normal 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
|
||||
48
docs/plans/phases/phase6sessions/session-01.md
Normal file
48
docs/plans/phases/phase6sessions/session-01.md
Normal 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 | 582–583 | 18 |
|
||||
| server/errors.go | 15 | 833–847 | 92 |
|
||||
| server/errors_gen.go | 6 | 848–853 | 158 |
|
||||
| server/proto.go | 6 | 2593–2598 | 237 |
|
||||
| server/ring.go | 6 | 2889–2894 | 34 |
|
||||
| server/rate_counter.go | 3 | 2797–2799 | 34 |
|
||||
| server/sdm.go | 5 | 2966–2970 | 39 |
|
||||
| server/nkey.go | 3 | 2440–2442 | 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 | 297–298 |
|
||||
| server/ring_test.go | 2 | 2794–2795 |
|
||||
| server/rate_counter_test.go | 1 | 2720 |
|
||||
| server/nkey_test.go | 9 | 2362–2370 |
|
||||
| server/trust_test.go | 3 | 3058–3060 |
|
||||
| **Total** | **17** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- None (leaf session)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/` — types and enums at root or `Internal/`
|
||||
42
docs/plans/phases/phase6sessions/session-02.md
Normal file
42
docs/plans/phases/phase6sessions/session-02.md
Normal 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 | 3485–3505 | 244 |
|
||||
| server/ipqueue.go | 14 | 1354–1367 | 175 |
|
||||
| server/sendq.go | 3 | 2971–2973 | 76 |
|
||||
| server/scheduler.go | 14 | 2952–2965 | 260 |
|
||||
| server/subject_transform.go | 16 | 3388–3403 | 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 | 3061–3073 |
|
||||
| server/ipqueue_test.go | 28 | 688–715 |
|
||||
| server/subject_transform_test.go | 4 | 2958–2961 |
|
||||
| server/split_test.go | 12 | 2929–2940 |
|
||||
| **Total** | **57** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Internal/`
|
||||
37
docs/plans/phases/phase6sessions/session-03.md
Normal file
37
docs/plans/phases/phase6sessions/session-03.md
Normal 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 | 2502–2587 | 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 | 2512–2597 |
|
||||
| server/config_check_test.go | 3 | 271–273 |
|
||||
| **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)
|
||||
48
docs/plans/phases/phase6sessions/session-04.md
Normal file
48
docs/plans/phases/phase6sessions/session-04.md
Normal 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 | 2050–2067 | 207 |
|
||||
| server/signal.go | 5 | 3155–3159 | 156 |
|
||||
| server/signal_wasm.go | 2 | 3160–3161 | 6 |
|
||||
| server/signal_windows.go | 2 | 3162–3163 | 79 |
|
||||
| server/service.go | 2 | 3148–3149 | 7 |
|
||||
| server/service_windows.go | 5 | 3150–3154 | 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 | 2017–2022 |
|
||||
| server/signal_test.go | 19 | 2910–2928 |
|
||||
| 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`
|
||||
40
docs/plans/phases/phase6sessions/session-05.md
Normal file
40
docs/plans/phases/phase6sessions/session-05.md
Normal 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 | 3404–3484 | 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 | 2962–3057 |
|
||||
| **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
|
||||
45
docs/plans/phases/phase6sessions/session-06.md
Normal file
45
docs/plans/phases/phase6sessions/session-06.md
Normal 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 | 350–380 | 1,498 |
|
||||
| server/auth_callout.go | 3 | 381–383 | 456 |
|
||||
| server/jwt.go | 6 | 1973–1978 | 205 |
|
||||
| server/ciphersuites.go | 3 | 384–386 | 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 | 142–153 |
|
||||
| server/auth_callout_test.go | 31 | 111–141 |
|
||||
| server/jwt_test.go | 88 | 1809–1896 |
|
||||
| **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
|
||||
39
docs/plans/phases/phase6sessions/session-07.md
Normal file
39
docs/plans/phases/phase6sessions/session-07.md
Normal 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 | 2588–2592 | 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 | 2598–2614 |
|
||||
| **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
|
||||
49
docs/plans/phases/phase6sessions/session-08.md
Normal file
49
docs/plans/phases/phase6sessions/session-08.md
Normal 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 | 387–571 | 5,680 |
|
||||
| server/client_proxyproto.go | 10 | 572–581 | 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 | 182–263 |
|
||||
| server/client_proxyproto_test.go | 23 | 159–181 |
|
||||
| server/closed_conns_test.go | 7 | 264–270 |
|
||||
| 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.
|
||||
52
docs/plans/phases/phase6sessions/session-09.md
Normal file
52
docs/plans/phases/phase6sessions/session-09.md
Normal 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 85–2575) | ~76 | 2974–3050 | ~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 | 2866–2885 |
|
||||
| **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)
|
||||
57
docs/plans/phases/phase6sessions/session-10.md
Normal file
57
docs/plans/phases/phase6sessions/session-10.md
Normal 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 2577–4782) | ~98 | 3051–3147 | ~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 | 2886–2907 |
|
||||
| server/benchmark_publish_test.go | 1 | 154 |
|
||||
| server/core_benchmarks_test.go | 4 | 274–277 |
|
||||
| **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
|
||||
52
docs/plans/phases/phase6sessions/session-11.md
Normal file
52
docs/plans/phases/phase6sessions/session-11.md
Normal 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 | 150–349 | 3,918 |
|
||||
| server/dirstore.go | 34 | 793–826 | 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 | 46–110 |
|
||||
| server/dirstore_test.go | 19 | 278–296 |
|
||||
| **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
|
||||
53
docs/plans/phases/phase6sessions/session-12.md
Normal file
53
docs/plans/phases/phase6sessions/session-12.md
Normal 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 | 854–950 | 2,445 |
|
||||
| server/monitor.go | 70 | 2166–2235 | 3,257 |
|
||||
| server/monitor_sort_opts.go | 16 | 2236–2251 | 48 |
|
||||
| server/msgtrace.go | 35 | 2405–2439 | 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 | 299–350 |
|
||||
| server/monitor_test.go | 103 | 2064–2166 |
|
||||
| server/msgtrace_test.go | 33 | 2329–2361 |
|
||||
| **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
|
||||
39
docs/plans/phases/phase6sessions/session-13.md
Normal file
39
docs/plans/phases/phase6sessions/session-13.md
Normal 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 | 2800–2888 | 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 | 2721–2793 |
|
||||
| **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
|
||||
41
docs/plans/phases/phase6sessions/session-14.md
Normal file
41
docs/plans/phases/phase6sessions/session-14.md
Normal 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 | 2895–2951 | 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 | 2796–2865 |
|
||||
| **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
|
||||
45
docs/plans/phases/phase6sessions/session-15.md
Normal file
45
docs/plans/phases/phase6sessions/session-15.md
Normal 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 | 1979–2049 | 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 | 1906–2016 |
|
||||
| server/leafnode_proxy_test.go | 9 | 1897–1905 |
|
||||
| **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
|
||||
47
docs/plans/phases/phase6sessions/session-16.md
Normal file
47
docs/plans/phases/phase6sessions/session-16.md
Normal 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 | 1263–1353 | 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 | 600–687 |
|
||||
| **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
|
||||
53
docs/plans/phases/phase6sessions/session-17.md
Normal file
53
docs/plans/phases/phase6sessions/session-17.md
Normal 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 | 3164–3194 | 391 |
|
||||
| server/memstore.go | 98 | 2068–2165 | 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 | 2941–2957 |
|
||||
| server/memstore_test.go | 41 | 2023–2063 |
|
||||
| **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)
|
||||
50
docs/plans/phases/phase6sessions/session-18.md
Normal file
50
docs/plans/phases/phase6sessions/session-18.md
Normal 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 | 951–1262 | 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 | 351–599 |
|
||||
| **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
|
||||
67
docs/plans/phases/phase6sessions/session-19.md
Normal file
67
docs/plans/phases/phase6sessions/session-19.md
Normal 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 | 1368–1451 | 2,481 |
|
||||
| server/jetstream_api.go | 56 | 1452–1507 | 4,269 |
|
||||
| server/jetstream_errors.go | 5 | 1751–1755 | 62 |
|
||||
| server/jetstream_errors_generated.go | 203 | 1756–1958 | 1,924 |
|
||||
| server/jetstream_events.go | 1 | 1959 | 25 |
|
||||
| server/jetstream_versioning.go | 13 | 1960–1972 | 175 |
|
||||
| server/jetstream_batching.go | 12 | 1508–1519 | 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 | 1466–1785 |
|
||||
| server/jetstream_errors_test.go | 4 | 1381–1384 |
|
||||
| server/jetstream_versioning_test.go | 18 | 1791–1808 |
|
||||
| server/jetstream_batching_test.go | 29 | 716–744 |
|
||||
| server/jetstream_jwt_test.go | 18 | 1385–1402 |
|
||||
| server/jetstream_tpm_test.go | 5 | 1786–1790 |
|
||||
| server/jetstream_benchmark_test.go | 12 | 745–756 |
|
||||
| **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)
|
||||
70
docs/plans/phases/phase6sessions/session-20.md
Normal file
70
docs/plans/phases/phase6sessions/session-20.md
Normal 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 | 2599–2796 | 4,078 |
|
||||
| server/jetstream_cluster.go | 231 | 1520–1750 | 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 | 2616–2719 |
|
||||
| server/jetstream_cluster_1_test.go | 151 | 757–907 |
|
||||
| server/jetstream_cluster_2_test.go | 123 | 908–1030 |
|
||||
| server/jetstream_cluster_3_test.go | 97 | 1031–1127 |
|
||||
| server/jetstream_cluster_4_test.go | 85 | 1128–1212 |
|
||||
| server/jetstream_cluster_long_test.go | 7 | 1213–1219 |
|
||||
| server/jetstream_super_cluster_test.go | 47 | 1419–1465 |
|
||||
| server/jetstream_meta_benchmark_test.go | 2 | 1416–1417 |
|
||||
| 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
|
||||
60
docs/plans/phases/phase6sessions/session-21.md
Normal file
60
docs/plans/phases/phase6sessions/session-21.md
Normal 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 | 3195–3387 | 6,980 |
|
||||
| server/consumer.go | 209 | 584–792 | 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 | 1220–1380 |
|
||||
| server/jetstream_leafnode_test.go | 13 | 1403–1415 |
|
||||
| server/norace_1_test.go | 100 | 2371–2470 |
|
||||
| server/norace_2_test.go | 41 | 2471–2511 |
|
||||
| **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#
|
||||
51
docs/plans/phases/phase6sessions/session-22.md
Normal file
51
docs/plans/phases/phase6sessions/session-22.md
Normal 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 | 2252–2404 | 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 | 2170–2328 |
|
||||
| server/mqtt_ex_test_test.go | 2 | 2168–2169 |
|
||||
| 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
|
||||
52
docs/plans/phases/phase6sessions/session-23.md
Normal file
52
docs/plans/phases/phase6sessions/session-23.md
Normal 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 | 3506–3543 | 1,265 |
|
||||
| server/ocsp.go | 20 | 2443–2462 | 880 |
|
||||
| server/ocsp_peer.go | 9 | 2463–2471 | 356 |
|
||||
| server/ocsp_responsecache.go | 30 | 2472–2501 | 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 | 3074–3182 |
|
||||
| server/certstore_windows_test.go | 4 | 155–158 |
|
||||
| **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
|
||||
10
dotnet/ZB.MOM.NatsNet.slnx
Normal file
10
dotnet/ZB.MOM.NatsNet.slnx
Normal 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
BIN
dotnet/porting.db
Normal file
Binary file not shown.
2
dotnet/src/ZB.MOM.NatsNet.Server.Host/Program.cs
Normal file
2
dotnet/src/ZB.MOM.NatsNet.Server.Host/Program.cs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Entry point placeholder - will be populated during server module porting
|
||||
Console.WriteLine("ZB.MOM.NatsNet.Server");
|
||||
@@ -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>
|
||||
273
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs
Normal file
273
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs
Normal 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');
|
||||
}
|
||||
}
|
||||
176
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs
Normal file
176
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthTypes.cs
Normal 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;
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
110
dotnet/src/ZB.MOM.NatsNet.Server/Auth/CipherSuites.cs
Normal file
110
dotnet/src/ZB.MOM.NatsNet.Server/Auth/CipherSuites.cs
Normal 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",
|
||||
];
|
||||
}
|
||||
}
|
||||
192
dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs
Normal file
192
dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs
Normal 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;
|
||||
}
|
||||
61
dotnet/src/ZB.MOM.NatsNet.Server/Auth/TpmKeyProvider.cs
Normal file
61
dotnet/src/ZB.MOM.NatsNet.Server/Auth/TpmKeyProvider.cs
Normal 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; } = [];
|
||||
}
|
||||
1211
dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs
Normal file
1211
dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs
Normal file
File diff suppressed because it is too large
Load Diff
375
dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs
Normal file
375
dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs
Normal 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;
|
||||
}
|
||||
100
dotnet/src/ZB.MOM.NatsNet.Server/Internal/AccessTimeService.cs
Normal file
100
dotnet/src/ZB.MOM.NatsNet.Server/Internal/AccessTimeService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
118
dotnet/src/ZB.MOM.NatsNet.Server/Internal/ClosedRingBuffer.cs
Normal file
118
dotnet/src/ZB.MOM.NatsNet.Server/Internal/ClosedRingBuffer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>></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() { }
|
||||
}
|
||||
@@ -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 <= 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
64
dotnet/src/ZB.MOM.NatsNet.Server/Internal/ElasticPointer.cs
Normal file
64
dotnet/src/ZB.MOM.NatsNet.Server/Internal/ElasticPointer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
265
dotnet/src/ZB.MOM.NatsNet.Server/Internal/IpQueue.cs
Normal file
265
dotnet/src/ZB.MOM.NatsNet.Server/Internal/IpQueue.cs
Normal 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 _);
|
||||
}
|
||||
431
dotnet/src/ZB.MOM.NatsNet.Server/Internal/MsgScheduling.cs
Normal file
431
dotnet/src/ZB.MOM.NatsNet.Server/Internal/MsgScheduling.cs
Normal 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
|
||||
}
|
||||
}
|
||||
187
dotnet/src/ZB.MOM.NatsNet.Server/Internal/NatsLogger.cs
Normal file
187
dotnet/src/ZB.MOM.NatsNet.Server/Internal/NatsLogger.cs
Normal 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);
|
||||
}
|
||||
@@ -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 (0–100 × 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 0–100; 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;
|
||||
}
|
||||
284
dotnet/src/ZB.MOM.NatsNet.Server/Internal/ProtoWire.cs
Normal file
284
dotnet/src/ZB.MOM.NatsNet.Server/Internal/ProtoWire.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
81
dotnet/src/ZB.MOM.NatsNet.Server/Internal/RateCounter.cs
Normal file
81
dotnet/src/ZB.MOM.NatsNet.Server/Internal/RateCounter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
437
dotnet/src/ZB.MOM.NatsNet.Server/Internal/ServerUtilities.cs
Normal file
437
dotnet/src/ZB.MOM.NatsNet.Server/Internal/ServerUtilities.cs
Normal 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];
|
||||
}
|
||||
145
dotnet/src/ZB.MOM.NatsNet.Server/Internal/SignalHandler.cs
Normal file
145
dotnet/src/ZB.MOM.NatsNet.Server/Internal/SignalHandler.cs
Normal 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,
|
||||
}
|
||||
842
dotnet/src/ZB.MOM.NatsNet.Server/Internal/SubjectTransform.cs
Normal file
842
dotnet/src/ZB.MOM.NatsNet.Server/Internal/SubjectTransform.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
77
dotnet/src/ZB.MOM.NatsNet.Server/Internal/Subscription.cs
Normal file
77
dotnet/src/ZB.MOM.NatsNet.Server/Internal/Subscription.cs
Normal 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;
|
||||
}
|
||||
95
dotnet/src/ZB.MOM.NatsNet.Server/Internal/SystemMemory.cs
Normal file
95
dotnet/src/ZB.MOM.NatsNet.Server/Internal/SystemMemory.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
389
dotnet/src/ZB.MOM.NatsNet.Server/NatsMessageHeaders.cs
Normal file
389
dotnet/src/ZB.MOM.NatsNet.Server/NatsMessageHeaders.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("ZB.MOM.NatsNet.Server.Tests")]
|
||||
[assembly: InternalsVisibleTo("ZB.MOM.NatsNet.Server.IntegrationTests")]
|
||||
@@ -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();
|
||||
}
|
||||
171
dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ParserTypes.cs
Normal file
171
dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ParserTypes.cs
Normal 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];
|
||||
}
|
||||
1255
dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ProtocolParser.cs
Normal file
1255
dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ProtocolParser.cs
Normal file
File diff suppressed because it is too large
Load Diff
604
dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ProxyProtocol.cs
Normal file
604
dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ProxyProtocol.cs
Normal 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 6–11).
|
||||
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 6–11).
|
||||
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) { }
|
||||
}
|
||||
228
dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs
Normal file
228
dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs
Normal 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,
|
||||
}
|
||||
450
dotnet/src/ZB.MOM.NatsNet.Server/ServerErrors.cs
Normal file
450
dotnet/src/ZB.MOM.NatsNet.Server/ServerErrors.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
477
dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs
Normal file
477
dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs
Normal 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();
|
||||
}
|
||||
569
dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs
Normal file
569
dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
238
dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs
Normal file
238
dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs
Normal 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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.NatsNet.Server.IntegrationTests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
246
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthTypesTests.cs
Normal file
246
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthTypesTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
320
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ClientTests.cs
Normal file
320
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ClientTests.cs
Normal 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!);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 697–715) 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 0–9 < 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");
|
||||
}
|
||||
}
|
||||
@@ -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 3068–3073) 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
Reference in New Issue
Block a user