feat: port session 07 — Protocol Parser, Auth extras (TPM/certidp/certstore), Internal utilities & data structures

Session 07 scope (5 features, 17 tests, ~1165 Go LOC):
- Protocol/ParserTypes.cs: ParserState enum (79 states), PublishArgument, ParseContext
- Protocol/IProtocolHandler.cs: handler interface decoupling parser from client
- Protocol/ProtocolParser.cs: Parse(), ProtoSnippet(), OverMaxControlLineLimit(),
  ProcessPub/HeaderPub/RoutedMsgArgs/RoutedHeaderMsgArgs, ClonePubArg(), GetHeader()
- tests/Protocol/ProtocolParserTests.cs: 17 tests via TestProtocolHandler stub

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

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

All 461 tests pass (460 unit + 1 integration). DB updated for features 2588-2592 and tests 2598-2614.
This commit is contained in:
Joseph Doherty
2026-02-26 13:16:56 -05:00
parent 0a54d342ba
commit 88b1391ef0
56 changed files with 9006 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
dotnet/porting.db Normal file

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,137 @@
// Copyright 2022-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using System.Security.Cryptography.X509Certificates;
namespace ZB.MOM.NatsNet.Server.Auth.CertificateStore;
/// <summary>
/// Windows certificate store location.
/// Mirrors the Go certstore <c>StoreType</c> enum (windowsCurrentUser=1, windowsLocalMachine=2).
/// </summary>
public enum StoreType
{
Empty = 0,
WindowsCurrentUser = 1,
WindowsLocalMachine = 2,
}
/// <summary>
/// Certificate lookup criterion.
/// Mirrors the Go certstore <c>MatchByType</c> enum (matchByIssuer=1, matchBySubject=2, matchByThumbprint=3).
/// </summary>
public enum MatchByType
{
Empty = 0,
Issuer = 1,
Subject = 2,
Thumbprint = 3,
}
/// <summary>
/// Result returned by <see cref="CertificateStoreService.TLSConfig"/>.
/// Mirrors the data that the Go <c>TLSConfig</c> populates into <c>*tls.Config</c>.
/// </summary>
public sealed class CertStoreTlsResult
{
public CertStoreTlsResult(X509Certificate2 leaf, X509Certificate2Collection? caCerts = null)
{
Leaf = leaf;
CaCerts = caCerts;
}
/// <summary>The leaf certificate (with private key) to use as the server/client identity.</summary>
public X509Certificate2 Leaf { get; }
/// <summary>Optional pool of CA certificates used to validate client certificates (mTLS).</summary>
public X509Certificate2Collection? CaCerts { get; }
}
/// <summary>
/// Error constants for the Windows certificate store module.
/// Mirrors certstore/errors.go.
/// </summary>
public static class CertStoreErrors
{
public static readonly InvalidOperationException ErrBadCryptoStoreProvider =
new("unable to open certificate store or store not available");
public static readonly InvalidOperationException ErrBadRSAHashAlgorithm =
new("unsupported RSA hash algorithm");
public static readonly InvalidOperationException ErrBadSigningAlgorithm =
new("unsupported signing algorithm");
public static readonly InvalidOperationException ErrStoreRSASigningError =
new("unable to obtain RSA signature from store");
public static readonly InvalidOperationException ErrStoreECDSASigningError =
new("unable to obtain ECDSA signature from store");
public static readonly InvalidOperationException ErrNoPrivateKeyStoreRef =
new("unable to obtain private key handle from store");
public static readonly InvalidOperationException ErrExtractingPrivateKeyMetadata =
new("unable to extract private key metadata");
public static readonly InvalidOperationException ErrExtractingECCPublicKey =
new("unable to extract ECC public key from store");
public static readonly InvalidOperationException ErrExtractingRSAPublicKey =
new("unable to extract RSA public key from store");
public static readonly InvalidOperationException ErrExtractingPublicKey =
new("unable to extract public key from store");
public static readonly InvalidOperationException ErrBadPublicKeyAlgorithm =
new("unsupported public key algorithm");
public static readonly InvalidOperationException ErrExtractPropertyFromKey =
new("unable to extract property from key");
public static readonly InvalidOperationException ErrBadECCCurveName =
new("unsupported ECC curve name");
public static readonly InvalidOperationException ErrFailedCertSearch =
new("unable to find certificate in store");
public static readonly InvalidOperationException ErrFailedX509Extract =
new("unable to extract x509 from certificate");
public static readonly InvalidOperationException ErrBadMatchByType =
new("cert match by type not implemented");
public static readonly InvalidOperationException ErrBadCertStore =
new("cert store type not implemented");
public static readonly InvalidOperationException ErrConflictCertFileAndStore =
new("'cert_file' and 'cert_store' may not both be configured");
public static readonly InvalidOperationException ErrBadCertStoreField =
new("expected 'cert_store' to be a valid non-empty string");
public static readonly InvalidOperationException ErrBadCertMatchByField =
new("expected 'cert_match_by' to be a valid non-empty string");
public static readonly InvalidOperationException ErrBadCertMatchField =
new("expected 'cert_match' to be a valid non-empty string");
public static readonly InvalidOperationException ErrBadCaCertMatchField =
new("expected 'ca_certs_match' to be a valid non-empty string array");
public static readonly InvalidOperationException ErrBadCertMatchSkipInvalidField =
new("expected 'cert_match_skip_invalid' to be a boolean");
public static readonly InvalidOperationException ErrOSNotCompatCertStore =
new("cert_store not compatible with current operating system");
}

View File

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

View File

@@ -0,0 +1,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; } = [];
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,483 @@
// Copyright 2023-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
// Internal node interface for the adaptive radix trie.
internal interface ISubjectTreeNode<T>
{
bool IsLeaf { get; }
byte[] Prefix { get; }
void AddChild(byte key, ISubjectTreeNode<T> child);
ISubjectTreeNode<T>? FindChild(byte key);
void DeleteChild(byte key);
bool IsFull { get; }
ISubjectTreeNode<T> Grow();
ISubjectTreeNode<T>? Shrink();
ISubjectTreeNode<T>[] Children();
int NumChildren { get; }
byte[] Path { get; }
(byte[][] remainingParts, bool matched) MatchParts(byte[][] parts);
string Kind { get; }
}
// Base class for non-leaf nodes, holding prefix and child count.
internal abstract class SubjectTreeMeta<T> : ISubjectTreeNode<T>
{
protected byte[] _prefix;
protected int _size;
protected SubjectTreeMeta(byte[] prefix)
{
_prefix = SubjectTreeParts.CopyBytes(prefix);
}
public bool IsLeaf => false;
public byte[] Prefix => _prefix;
public int NumChildren => _size;
public byte[] Path => _prefix;
public void SetPrefix(byte[] prefix)
{
_prefix = SubjectTreeParts.CopyBytes(prefix);
}
public (byte[][] remainingParts, bool matched) MatchParts(byte[][] parts)
=> SubjectTreeParts.MatchParts(parts, _prefix);
public abstract void AddChild(byte key, ISubjectTreeNode<T> child);
public abstract ISubjectTreeNode<T>? FindChild(byte key);
public abstract void DeleteChild(byte key);
public abstract bool IsFull { get; }
public abstract ISubjectTreeNode<T> Grow();
public abstract ISubjectTreeNode<T>? Shrink();
public abstract ISubjectTreeNode<T>[] Children();
public abstract string Kind { get; }
}
// Leaf node storing the terminal value plus a suffix byte[].
internal sealed class SubjectTreeLeaf<T> : ISubjectTreeNode<T>
{
public T Value;
public byte[] Suffix;
public SubjectTreeLeaf(byte[] suffix, T value)
{
Suffix = SubjectTreeParts.CopyBytes(suffix);
Value = value;
}
public bool IsLeaf => true;
public byte[] Prefix => Array.Empty<byte>();
public int NumChildren => 0;
public byte[] Path => Suffix;
public string Kind => "LEAF";
public bool Match(ReadOnlySpan<byte> subject)
=> subject.SequenceEqual(Suffix);
public void SetSuffix(byte[] suffix)
=> Suffix = SubjectTreeParts.CopyBytes(suffix);
public bool IsFull => true;
public (byte[][] remainingParts, bool matched) MatchParts(byte[][] parts)
=> SubjectTreeParts.MatchParts(parts, Suffix);
// Leaf nodes do not support child operations.
public void AddChild(byte key, ISubjectTreeNode<T> child)
=> throw new InvalidOperationException("AddChild called on leaf");
public ISubjectTreeNode<T>? FindChild(byte key)
=> throw new InvalidOperationException("FindChild called on leaf");
public void DeleteChild(byte key)
=> throw new InvalidOperationException("DeleteChild called on leaf");
public ISubjectTreeNode<T> Grow()
=> throw new InvalidOperationException("Grow called on leaf");
public ISubjectTreeNode<T>? Shrink()
=> throw new InvalidOperationException("Shrink called on leaf");
public ISubjectTreeNode<T>[] Children()
=> Array.Empty<ISubjectTreeNode<T>>();
}
// Node with up to 4 children (keys + children arrays, unsorted).
internal sealed class SubjectTreeNode4<T> : SubjectTreeMeta<T>
{
private readonly byte[] _keys = new byte[4];
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[4];
public SubjectTreeNode4(byte[] prefix) : base(prefix) { }
public override string Kind => "NODE4";
public override void AddChild(byte key, ISubjectTreeNode<T> child)
{
if (_size >= 4) throw new InvalidOperationException("node4 full!");
_keys[_size] = key;
_children[_size] = child;
_size++;
}
public override ISubjectTreeNode<T>? FindChild(byte key)
{
for (var i = 0; i < _size; i++)
{
if (_keys[i] == key) return _children[i];
}
return null;
}
public override void DeleteChild(byte key)
{
for (var i = 0; i < _size; i++)
{
if (_keys[i] == key)
{
var last = _size - 1;
if (i < last)
{
_keys[i] = _keys[last];
_children[i] = _children[last];
}
_keys[last] = 0;
_children[last] = null;
_size--;
return;
}
}
}
public override bool IsFull => _size >= 4;
public override ISubjectTreeNode<T> Grow()
{
var nn = new SubjectTreeNode10<T>(_prefix);
for (var i = 0; i < 4; i++)
nn.AddChild(_keys[i], _children[i]!);
return nn;
}
public override ISubjectTreeNode<T>? Shrink()
{
if (_size == 1) return _children[0];
return null;
}
public override ISubjectTreeNode<T>[] Children()
{
var result = new ISubjectTreeNode<T>[_size];
for (var i = 0; i < _size; i++)
result[i] = _children[i]!;
return result;
}
// Internal access for tests.
internal byte GetKey(int index) => _keys[index];
internal ISubjectTreeNode<T>? GetChild(int index) => _children[index];
}
// Node with up to 10 children (for numeric token segments).
internal sealed class SubjectTreeNode10<T> : SubjectTreeMeta<T>
{
private readonly byte[] _keys = new byte[10];
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[10];
public SubjectTreeNode10(byte[] prefix) : base(prefix) { }
public override string Kind => "NODE10";
public override void AddChild(byte key, ISubjectTreeNode<T> child)
{
if (_size >= 10) throw new InvalidOperationException("node10 full!");
_keys[_size] = key;
_children[_size] = child;
_size++;
}
public override ISubjectTreeNode<T>? FindChild(byte key)
{
for (var i = 0; i < _size; i++)
{
if (_keys[i] == key) return _children[i];
}
return null;
}
public override void DeleteChild(byte key)
{
for (var i = 0; i < _size; i++)
{
if (_keys[i] == key)
{
var last = _size - 1;
if (i < last)
{
_keys[i] = _keys[last];
_children[i] = _children[last];
}
_keys[last] = 0;
_children[last] = null;
_size--;
return;
}
}
}
public override bool IsFull => _size >= 10;
public override ISubjectTreeNode<T> Grow()
{
var nn = new SubjectTreeNode16<T>(_prefix);
for (var i = 0; i < _size; i++)
nn.AddChild(_keys[i], _children[i]!);
return nn;
}
public override ISubjectTreeNode<T>? Shrink()
{
if (_size > 4) return null;
var nn = new SubjectTreeNode4<T>(Array.Empty<byte>());
for (var i = 0; i < _size; i++)
nn.AddChild(_keys[i], _children[i]!);
return nn;
}
public override ISubjectTreeNode<T>[] Children()
{
var result = new ISubjectTreeNode<T>[_size];
for (var i = 0; i < _size; i++)
result[i] = _children[i]!;
return result;
}
}
// Node with up to 16 children.
internal sealed class SubjectTreeNode16<T> : SubjectTreeMeta<T>
{
private readonly byte[] _keys = new byte[16];
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[16];
public SubjectTreeNode16(byte[] prefix) : base(prefix) { }
public override string Kind => "NODE16";
public override void AddChild(byte key, ISubjectTreeNode<T> child)
{
if (_size >= 16) throw new InvalidOperationException("node16 full!");
_keys[_size] = key;
_children[_size] = child;
_size++;
}
public override ISubjectTreeNode<T>? FindChild(byte key)
{
for (var i = 0; i < _size; i++)
{
if (_keys[i] == key) return _children[i];
}
return null;
}
public override void DeleteChild(byte key)
{
for (var i = 0; i < _size; i++)
{
if (_keys[i] == key)
{
var last = _size - 1;
if (i < last)
{
_keys[i] = _keys[last];
_children[i] = _children[last];
}
_keys[last] = 0;
_children[last] = null;
_size--;
return;
}
}
}
public override bool IsFull => _size >= 16;
public override ISubjectTreeNode<T> Grow()
{
var nn = new SubjectTreeNode48<T>(_prefix);
for (var i = 0; i < _size; i++)
nn.AddChild(_keys[i], _children[i]!);
return nn;
}
public override ISubjectTreeNode<T>? Shrink()
{
if (_size > 10) return null;
var nn = new SubjectTreeNode10<T>(Array.Empty<byte>());
for (var i = 0; i < _size; i++)
nn.AddChild(_keys[i], _children[i]!);
return nn;
}
public override ISubjectTreeNode<T>[] Children()
{
var result = new ISubjectTreeNode<T>[_size];
for (var i = 0; i < _size; i++)
result[i] = _children[i]!;
return result;
}
}
// Node with up to 48 children, using a 256-byte key index (1-indexed, 0 means empty).
internal sealed class SubjectTreeNode48<T> : SubjectTreeMeta<T>
{
// _keyIndex[byte] = 1-based index into _children; 0 means no entry.
private readonly byte[] _keyIndex = new byte[256];
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[48];
public SubjectTreeNode48(byte[] prefix) : base(prefix) { }
public override string Kind => "NODE48";
public override void AddChild(byte key, ISubjectTreeNode<T> child)
{
if (_size >= 48) throw new InvalidOperationException("node48 full!");
_children[_size] = child;
_keyIndex[key] = (byte)(_size + 1); // 1-indexed
_size++;
}
public override ISubjectTreeNode<T>? FindChild(byte key)
{
var i = _keyIndex[key];
if (i == 0) return null;
return _children[i - 1];
}
public override void DeleteChild(byte key)
{
var i = _keyIndex[key];
if (i == 0) return;
i--; // Convert from 1-indexed
var last = _size - 1;
if (i < last)
{
_children[i] = _children[last];
// Find which key index points to 'last' and redirect it to 'i'.
for (var ic = 0; ic < 256; ic++)
{
if (_keyIndex[ic] == last + 1)
{
_keyIndex[ic] = (byte)(i + 1);
break;
}
}
}
_children[last] = null;
_keyIndex[key] = 0;
_size--;
}
public override bool IsFull => _size >= 48;
public override ISubjectTreeNode<T> Grow()
{
var nn = new SubjectTreeNode256<T>(_prefix);
for (var c = 0; c < 256; c++)
{
var i = _keyIndex[c];
if (i > 0)
nn.AddChild((byte)c, _children[i - 1]!);
}
return nn;
}
public override ISubjectTreeNode<T>? Shrink()
{
if (_size > 16) return null;
var nn = new SubjectTreeNode16<T>(Array.Empty<byte>());
for (var c = 0; c < 256; c++)
{
var i = _keyIndex[c];
if (i > 0)
nn.AddChild((byte)c, _children[i - 1]!);
}
return nn;
}
public override ISubjectTreeNode<T>[] Children()
{
var result = new ISubjectTreeNode<T>[_size];
var idx = 0;
for (var i = 0; i < _size; i++)
{
if (_children[i] != null)
result[idx++] = _children[i]!;
}
return result[..idx];
}
// Internal access for tests.
internal byte GetKeyIndex(int key) => _keyIndex[key];
internal ISubjectTreeNode<T>? GetChildAt(int index) => _children[index];
}
// Node with 256 children, indexed directly by byte value.
internal sealed class SubjectTreeNode256<T> : SubjectTreeMeta<T>
{
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[256];
public SubjectTreeNode256(byte[] prefix) : base(prefix) { }
public override string Kind => "NODE256";
public override void AddChild(byte key, ISubjectTreeNode<T> child)
{
_children[key] = child;
_size++;
}
public override ISubjectTreeNode<T>? FindChild(byte key)
=> _children[key];
public override void DeleteChild(byte key)
{
if (_children[key] != null)
{
_children[key] = null;
_size--;
}
}
public override bool IsFull => false;
public override ISubjectTreeNode<T> Grow()
=> throw new InvalidOperationException("Grow cannot be called on node256");
public override ISubjectTreeNode<T>? Shrink()
{
if (_size > 48) return null;
var nn = new SubjectTreeNode48<T>(Array.Empty<byte>());
for (var c = 0; c < 256; c++)
{
if (_children[c] != null)
nn.AddChild((byte)c, _children[c]!);
}
return nn;
}
public override ISubjectTreeNode<T>[] Children()
=> _children.Where(c => c != null).Select(c => c!).ToArray();
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,80 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/parser.go and server/client.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Protocol;
/// <summary>
/// Interface for the protocol handler callbacks invoked by <see cref="ProtocolParser.Parse"/>.
/// Decouples the state machine from the client implementation.
/// The client connection will implement this interface in later sessions.
/// </summary>
public interface IProtocolHandler
{
// ---- Dynamic connection state ----
bool IsMqtt { get; }
bool Trace { get; }
bool HasMappings { get; }
bool IsAwaitingAuth { get; }
/// <summary>
/// Attempts to register the no-auth user for this connection.
/// Returns true if a no-auth user was found and registered (allowing parse to continue).
/// </summary>
bool TryRegisterNoAuthUser();
/// <summary>
/// Returns true if this is a gateway inbound connection that has not yet received CONNECT.
/// </summary>
bool IsGatewayInboundNotConnected { get; }
// ---- Protocol action handlers ----
Exception? ProcessConnect(byte[] arg);
Exception? ProcessInfo(byte[] arg);
void ProcessPing();
void ProcessPong();
void ProcessErr(string arg);
// ---- Sub/unsub handlers (kind-specific) ----
Exception? ProcessClientSub(byte[] arg);
Exception? ProcessClientUnsub(byte[] arg);
Exception? ProcessRemoteSub(byte[] arg, bool isLeaf);
Exception? ProcessRemoteUnsub(byte[] arg, bool isLeafUnsub);
Exception? ProcessGatewayRSub(byte[] arg);
Exception? ProcessGatewayRUnsub(byte[] arg);
Exception? ProcessLeafSub(byte[] arg);
Exception? ProcessLeafUnsub(byte[] arg);
Exception? ProcessAccountSub(byte[] arg);
void ProcessAccountUnsub(byte[] arg);
// ---- Message processing ----
void ProcessInboundMsg(byte[] msg);
bool SelectMappedSubject();
// ---- Tracing ----
void TraceInOp(string name, byte[]? arg);
void TraceMsg(byte[] msg);
// ---- Error handling ----
void SendErr(string msg);
void AuthViolation();
void CloseConnection(int reason);
string KindString();
}

View File

@@ -0,0 +1,171 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/parser.go in the NATS server Go source.
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.Protocol;
/// <summary>
/// Parser state machine states.
/// Mirrors the Go <c>parserState</c> const block in parser.go (79 states).
/// </summary>
public enum ParserState
{
OpStart = 0,
OpPlus,
OpPlusO,
OpPlusOk,
OpMinus,
OpMinusE,
OpMinusEr,
OpMinusErr,
OpMinusErrSpc,
MinusErrArg,
OpC,
OpCo,
OpCon,
OpConn,
OpConne,
OpConnec,
OpConnect,
ConnectArg,
OpH,
OpHp,
OpHpu,
OpHpub,
OpHpubSpc,
HpubArg,
OpHm,
OpHms,
OpHmsg,
OpHmsgSpc,
HmsgArg,
OpP,
OpPu,
OpPub,
OpPubSpc,
PubArg,
OpPi,
OpPin,
OpPing,
OpPo,
OpPon,
OpPong,
MsgPayload,
MsgEndR,
MsgEndN,
OpS,
OpSu,
OpSub,
OpSubSpc,
SubArg,
OpA,
OpAsub,
OpAsubSpc,
AsubArg,
OpAusub,
OpAusubSpc,
AusubArg,
OpL,
OpLs,
OpR,
OpRs,
OpU,
OpUn,
OpUns,
OpUnsu,
OpUnsub,
OpUnsubSpc,
UnsubArg,
OpM,
OpMs,
OpMsg,
OpMsgSpc,
MsgArg,
OpI,
OpIn,
OpInf,
OpInfo,
InfoArg,
}
/// <summary>
/// Parsed publish/message arguments.
/// Mirrors Go <c>pubArg</c> struct in parser.go.
/// </summary>
public sealed class PublishArgument
{
public byte[]? Arg { get; set; }
public byte[]? PaCache { get; set; }
public byte[]? Origin { get; set; }
public byte[]? Account { get; set; }
public byte[]? Subject { get; set; }
public byte[]? Deliver { get; set; }
public byte[]? Mapped { get; set; }
public byte[]? Reply { get; set; }
public byte[]? SizeBytes { get; set; }
public byte[]? HeaderBytes { get; set; }
public List<byte[]>? Queues { get; set; }
public int Size { get; set; }
public int HeaderSize { get; set; } = -1;
public bool Delivered { get; set; }
/// <summary>Resets all fields to their defaults.</summary>
public void Reset()
{
Arg = null;
PaCache = null;
Origin = null;
Account = null;
Subject = null;
Deliver = null;
Mapped = null;
Reply = null;
SizeBytes = null;
HeaderBytes = null;
Queues = null;
Size = 0;
HeaderSize = -1;
Delivered = false;
}
}
/// <summary>
/// Holds the parser state for a single connection.
/// Mirrors Go <c>parseState</c> struct embedded in <c>client</c>.
/// </summary>
public sealed class ParseContext
{
// ---- Parser state ----
public ParserState State { get; set; }
public byte Op { get; set; }
public int ArgStart { get; set; }
public int Drop { get; set; }
public PublishArgument Pa { get; } = new();
public byte[]? ArgBuf { get; set; }
public byte[]? MsgBuf { get; set; }
// ---- Connection-level properties (set once at creation) ----
public ClientKind Kind { get; set; }
public int MaxControlLine { get; set; } = ServerConstants.MaxControlLineSize;
public int MaxPayload { get; set; } = -1;
public bool HasHeaders { get; set; }
// ---- Internal scratch buffer ----
internal byte[] Scratch { get; } = new byte[ServerConstants.MaxControlLineSize];
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,511 @@
// Copyright 2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server.Tests.Internal.DataStructures;
/// <summary>
/// Ports all 21 tests from Go's gsl/gsl_test.go.
/// </summary>
public sealed class GenericSublistTests
{
// -------------------------------------------------------------------------
// Helpers (mirror Go's require_* functions)
// -------------------------------------------------------------------------
/// <summary>
/// Counts how many values the sublist matches for <paramref name="subject"/>
/// and asserts that count equals <paramref name="expected"/>.
/// Mirrors Go's <c>require_Matches</c>.
/// </summary>
private static void RequireMatches<T>(GenericSublist<T> s, string subject, int expected)
where T : notnull
{
var matches = 0;
s.Match(subject, _ => matches++);
matches.ShouldBe(expected);
}
// -------------------------------------------------------------------------
// TestGenericSublistInit
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistInit()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Count.ShouldBe(0u);
}
// -------------------------------------------------------------------------
// TestGenericSublistInsertCount
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistInsertCount()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("foo", EmptyStruct.Value);
s.Insert("bar", EmptyStruct.Value);
s.Insert("foo.bar", EmptyStruct.Value);
s.Count.ShouldBe(3u);
}
// -------------------------------------------------------------------------
// TestGenericSublistSimple
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistSimple()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("foo", EmptyStruct.Value);
RequireMatches(s, "foo", 1);
}
// -------------------------------------------------------------------------
// TestGenericSublistSimpleMultiTokens
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistSimpleMultiTokens()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("foo.bar.baz", EmptyStruct.Value);
RequireMatches(s, "foo.bar.baz", 1);
}
// -------------------------------------------------------------------------
// TestGenericSublistPartialWildcard
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistPartialWildcard()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("a.b.c", EmptyStruct.Value);
s.Insert("a.*.c", EmptyStruct.Value);
RequireMatches(s, "a.b.c", 2);
}
// -------------------------------------------------------------------------
// TestGenericSublistPartialWildcardAtEnd
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistPartialWildcardAtEnd()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("a.b.c", EmptyStruct.Value);
s.Insert("a.b.*", EmptyStruct.Value);
RequireMatches(s, "a.b.c", 2);
}
// -------------------------------------------------------------------------
// TestGenericSublistFullWildcard
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistFullWildcard()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("a.b.c", EmptyStruct.Value);
s.Insert("a.>", EmptyStruct.Value);
RequireMatches(s, "a.b.c", 2);
RequireMatches(s, "a.>", 1);
}
// -------------------------------------------------------------------------
// TestGenericSublistRemove
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemove()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("a.b.c.d", EmptyStruct.Value);
s.Count.ShouldBe(1u);
RequireMatches(s, "a.b.c.d", 1);
s.Remove("a.b.c.d", EmptyStruct.Value);
s.Count.ShouldBe(0u);
RequireMatches(s, "a.b.c.d", 0);
}
// -------------------------------------------------------------------------
// TestGenericSublistRemoveWildcard
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemoveWildcard()
{
var s = GenericSublist<int>.NewSublist();
s.Insert("a.b.c.d", 11);
s.Insert("a.b.*.d", 22);
s.Insert("a.b.>", 33);
s.Count.ShouldBe(3u);
RequireMatches(s, "a.b.c.d", 3);
s.Remove("a.b.*.d", 22);
s.Count.ShouldBe(2u);
RequireMatches(s, "a.b.c.d", 2);
s.Remove("a.b.>", 33);
s.Count.ShouldBe(1u);
RequireMatches(s, "a.b.c.d", 1);
s.Remove("a.b.c.d", 11);
s.Count.ShouldBe(0u);
RequireMatches(s, "a.b.c.d", 0);
}
// -------------------------------------------------------------------------
// TestGenericSublistRemoveCleanup
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemoveCleanup()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.NumLevels().ShouldBe(0);
s.Insert("a.b.c.d.e.f", EmptyStruct.Value);
s.NumLevels().ShouldBe(6);
s.Remove("a.b.c.d.e.f", EmptyStruct.Value);
s.NumLevels().ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestGenericSublistRemoveCleanupWildcards
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemoveCleanupWildcards()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.NumLevels().ShouldBe(0);
s.Insert("a.b.*.d.e.>", EmptyStruct.Value);
s.NumLevels().ShouldBe(6);
s.Remove("a.b.*.d.e.>", EmptyStruct.Value);
s.NumLevels().ShouldBe(0);
}
// -------------------------------------------------------------------------
// TestGenericSublistInvalidSubjectsInsert
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistInvalidSubjectsInsert()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
// Insert, or subscriptions, can have wildcards, but not empty tokens,
// and can not have a FWC that is not the terminal token.
Should.Throw<ArgumentException>(() => s.Insert(".foo", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Insert("foo.", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Insert("foo..bar", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Insert("foo.bar..baz", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Insert("foo.>.baz", EmptyStruct.Value));
}
// -------------------------------------------------------------------------
// TestGenericSublistBadSubjectOnRemove
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistBadSubjectOnRemove()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
Should.Throw<ArgumentException>(() => s.Insert("a.b..d", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Remove("a.b..d", EmptyStruct.Value));
Should.Throw<ArgumentException>(() => s.Remove("a.>.b", EmptyStruct.Value));
}
// -------------------------------------------------------------------------
// TestGenericSublistTwoTokenPubMatchSingleTokenSub
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistTwoTokenPubMatchSingleTokenSub()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert("foo", EmptyStruct.Value);
RequireMatches(s, "foo", 1);
RequireMatches(s, "foo.bar", 0);
}
// -------------------------------------------------------------------------
// TestGenericSublistInsertWithWildcardsAsLiterals
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistInsertWithWildcardsAsLiterals()
{
var s = GenericSublist<int>.NewSublist();
var subjects = new[] { "foo.*-", "foo.>-" };
for (var i = 0; i < subjects.Length; i++)
{
var subject = subjects[i];
s.Insert(subject, i);
RequireMatches(s, "foo.bar", 0);
RequireMatches(s, subject, 1);
}
}
// -------------------------------------------------------------------------
// TestGenericSublistRemoveWithWildcardsAsLiterals
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistRemoveWithWildcardsAsLiterals()
{
var s = GenericSublist<int>.NewSublist();
var subjects = new[] { "foo.*-", "foo.>-" };
for (var i = 0; i < subjects.Length; i++)
{
var subject = subjects[i];
s.Insert(subject, i);
RequireMatches(s, "foo.bar", 0);
RequireMatches(s, subject, 1);
Should.Throw<KeyNotFoundException>(() => s.Remove("foo.bar", i));
s.Count.ShouldBe(1u);
s.Remove(subject, i);
s.Count.ShouldBe(0u);
}
}
// -------------------------------------------------------------------------
// TestGenericSublistMatchWithEmptyTokens
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistMatchWithEmptyTokens()
{
var s = GenericSublist<EmptyStruct>.NewSublist();
s.Insert(">", EmptyStruct.Value);
var subjects = new[]
{
".foo", "..foo", "foo..", "foo.", "foo..bar", "foo...bar"
};
foreach (var subject in subjects)
{
RequireMatches(s, subject, 0);
}
}
// -------------------------------------------------------------------------
// TestGenericSublistHasInterest
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistHasInterest()
{
var s = GenericSublist<int>.NewSublist();
s.Insert("foo", 11);
// Expect to find that "foo" matches but "bar" doesn't.
s.HasInterest("foo").ShouldBeTrue();
s.HasInterest("bar").ShouldBeFalse();
// Call Match on a subject we know there is no match.
RequireMatches(s, "bar", 0);
s.HasInterest("bar").ShouldBeFalse();
// Remove fooSub and check interest again.
s.Remove("foo", 11);
s.HasInterest("foo").ShouldBeFalse();
// Try with some wildcards.
s.Insert("foo.*", 22);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.bar.baz").ShouldBeFalse();
// Remove sub, there should be no interest.
s.Remove("foo.*", 22);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeFalse();
s.HasInterest("foo.bar.baz").ShouldBeFalse();
s.Insert("foo.>", 33);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.bar.baz").ShouldBeTrue();
s.Remove("foo.>", 33);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeFalse();
s.HasInterest("foo.bar.baz").ShouldBeFalse();
s.Insert("*.>", 44);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.baz").ShouldBeTrue();
s.Remove("*.>", 44);
s.Insert("*.bar", 55);
s.HasInterest("foo").ShouldBeFalse();
s.HasInterest("foo.bar").ShouldBeTrue();
s.HasInterest("foo.baz").ShouldBeFalse();
s.Remove("*.bar", 55);
s.Insert("*", 66);
s.HasInterest("foo").ShouldBeTrue();
s.HasInterest("foo.bar").ShouldBeFalse();
s.Remove("*", 66);
}
// -------------------------------------------------------------------------
// TestGenericSublistHasInterestOverlapping
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistHasInterestOverlapping()
{
var s = GenericSublist<int>.NewSublist();
s.Insert("stream.A.child", 11);
s.Insert("stream.*", 11);
s.HasInterest("stream.A.child").ShouldBeTrue();
s.HasInterest("stream.A").ShouldBeTrue();
}
// -------------------------------------------------------------------------
// TestGenericSublistHasInterestStartingInRace
// Tests that HasInterestStartingIn is safe to call concurrently with
// modifications to the sublist. Mirrors Go's goroutine test using Tasks.
// -------------------------------------------------------------------------
[Fact]
public async Task TestGenericSublistHasInterestStartingInRace()
{
var s = GenericSublist<int>.NewSublist();
// Pre-populate with some patterns.
for (var i = 0; i < 10; i++)
{
s.Insert("foo.bar.baz", i);
s.Insert("foo.*.baz", i + 10);
s.Insert("foo.>", i + 20);
}
const int iterations = 1000;
// Task 1: repeatedly call HasInterestStartingIn.
var task1 = Task.Run(() =>
{
for (var i = 0; i < iterations; i++)
{
s.HasInterestStartingIn("foo");
s.HasInterestStartingIn("foo.bar");
s.HasInterestStartingIn("foo.bar.baz");
s.HasInterestStartingIn("other.subject");
}
});
// Task 2: repeatedly modify the sublist.
var task2 = Task.Run(() =>
{
for (var i = 0; i < iterations; i++)
{
var val = 1000 + i;
var dynSubject = "test.subject." + (char)('a' + i % 26);
s.Insert(dynSubject, val);
s.Insert("foo.*.test", val);
// Remove may fail if not found (concurrent), so swallow KeyNotFoundException.
try { s.Remove(dynSubject, val); } catch (KeyNotFoundException) { }
try { s.Remove("foo.*.test", val); } catch (KeyNotFoundException) { }
}
});
// Task 3: also call HasInterest (which does lock).
var task3 = Task.Run(() =>
{
for (var i = 0; i < iterations; i++)
{
s.HasInterest("foo.bar.baz");
s.HasInterest("foo.something.baz");
}
});
await Task.WhenAll(task1, task2, task3);
}
// -------------------------------------------------------------------------
// TestGenericSublistNumInterest
// -------------------------------------------------------------------------
[Fact]
public void TestGenericSublistNumInterest()
{
var s = GenericSublist<int>.NewSublist();
s.Insert("foo", 11);
void RequireNumInterest(string subj, int expected)
{
RequireMatches(s, subj, expected);
s.NumInterest(subj).ShouldBe(expected);
}
// Expect to find that "foo" matches but "bar" doesn't.
RequireNumInterest("foo", 1);
RequireNumInterest("bar", 0);
// Remove fooSub and check interest again.
s.Remove("foo", 11);
RequireNumInterest("foo", 0);
// Try with some wildcards.
s.Insert("foo.*", 22);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 0);
// Remove sub, there should be no interest.
s.Remove("foo.*", 22);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 0);
RequireNumInterest("foo.bar.baz", 0);
s.Insert("foo.>", 33);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 1);
s.Remove("foo.>", 33);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 0);
RequireNumInterest("foo.bar.baz", 0);
s.Insert("*.>", 44);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 1);
s.Remove("*.>", 44);
s.Insert("*.bar", 55);
RequireNumInterest("foo", 0);
RequireNumInterest("foo.bar", 1);
RequireNumInterest("foo.bar.baz", 0);
s.Remove("*.bar", 55);
s.Insert("*", 66);
RequireNumInterest("foo", 1);
RequireNumInterest("foo.bar", 0);
s.Remove("*", 66);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,798 @@
// 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 System.Text;
using Shouldly;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Protocol;
namespace ZB.MOM.NatsNet.Server.Tests.Protocol;
/// <summary>
/// Tests for the NATS protocol parser.
/// Mirrors Go parser_test.go — 17 test functions.
/// </summary>
public class ProtocolParserTests
{
// =====================================================================
// Test helpers — mirrors Go dummyClient/dummyRouteClient
// =====================================================================
private static ParseContext DummyClient() => new()
{
Kind = ClientKind.Client,
MaxControlLine = ServerConstants.MaxControlLineSize,
MaxPayload = -1,
HasHeaders = false,
};
private static ParseContext DummyRouteClient() => new()
{
Kind = ClientKind.Router,
MaxControlLine = ServerConstants.MaxControlLineSize,
MaxPayload = -1,
};
private static TestProtocolHandler DummyHandler() => new();
// =====================================================================
// TestParsePing — Go test ID 2598
// =====================================================================
[Fact]
public void ParsePing_ByteByByte()
{
var c = DummyClient();
var h = DummyHandler();
c.State.ShouldBe(ParserState.OpStart);
var ping = "PING\r\n"u8.ToArray();
ProtocolParser.Parse(c, h, ping[..1]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpP);
ProtocolParser.Parse(c, h, ping[1..2]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPi);
ProtocolParser.Parse(c, h, ping[2..3]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPin);
ProtocolParser.Parse(c, h, ping[3..4]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPing);
ProtocolParser.Parse(c, h, ping[4..5]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPing);
ProtocolParser.Parse(c, h, ping[5..6]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
h.PingCount.ShouldBe(1);
// Full message
ProtocolParser.Parse(c, h, ping).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
h.PingCount.ShouldBe(2);
// Should tolerate spaces
var pingSpaces = "PING \r"u8.ToArray();
ProtocolParser.Parse(c, h, pingSpaces).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPing);
c.State = ParserState.OpStart;
var pingSpaces2 = "PING \r \n"u8.ToArray();
ProtocolParser.Parse(c, h, pingSpaces2).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
}
// =====================================================================
// TestParsePong — Go test ID 2599
// =====================================================================
[Fact]
public void ParsePong_ByteByByte()
{
var c = DummyClient();
var h = DummyHandler();
c.State.ShouldBe(ParserState.OpStart);
var pong = "PONG\r\n"u8.ToArray();
ProtocolParser.Parse(c, h, pong[..1]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpP);
ProtocolParser.Parse(c, h, pong[1..2]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPo);
ProtocolParser.Parse(c, h, pong[2..3]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPon);
ProtocolParser.Parse(c, h, pong[3..4]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPong);
ProtocolParser.Parse(c, h, pong[4..5]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPong);
ProtocolParser.Parse(c, h, pong[5..6]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
h.PongCount.ShouldBe(1);
// Full message
ProtocolParser.Parse(c, h, pong).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
h.PongCount.ShouldBe(2);
// Should tolerate spaces
var pongSpaces = "PONG \r"u8.ToArray();
ProtocolParser.Parse(c, h, pongSpaces).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPong);
c.State = ParserState.OpStart;
var pongSpaces2 = "PONG \r \n"u8.ToArray();
ProtocolParser.Parse(c, h, pongSpaces2).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
}
// =====================================================================
// TestParseConnect — Go test ID 2600
// =====================================================================
[Fact]
public void ParseConnect_ParsesCorrectly()
{
var c = DummyClient();
var h = DummyHandler();
var connect = Encoding.ASCII.GetBytes(
"CONNECT {\"verbose\":false,\"pedantic\":true,\"tls_required\":false}\r\n");
ProtocolParser.Parse(c, h, connect).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
h.ConnectArgs.ShouldNotBeNull();
// Check saved state: arg start should be 8 (after "CONNECT ")
c.ArgStart.ShouldBe(connect.Length); // After full parse, ArgStart is past the end
}
// =====================================================================
// TestParseSub — Go test ID 2601
// =====================================================================
[Fact]
public void ParseSub_SetsState()
{
var c = DummyClient();
var h = DummyHandler();
var sub = "SUB foo 1\r"u8.ToArray();
ProtocolParser.Parse(c, h, sub).ShouldBeNull();
c.State.ShouldBe(ParserState.SubArg);
// The arg buffer should have been set up for split buffer
c.ArgBuf.ShouldNotBeNull();
Encoding.ASCII.GetString(c.ArgBuf!).ShouldBe("foo 1");
}
// =====================================================================
// TestParsePub — Go test ID 2602
// =====================================================================
[Fact]
public void ParsePub_ParsesSubjectReplySize()
{
var c = DummyClient();
var h = DummyHandler();
// Simple PUB
var pub = "PUB foo 5\r\nhello\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo");
c.Pa.Reply.ShouldBeNull();
c.Pa.Size.ShouldBe(5);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// PUB with reply
pub = "PUB foo.bar INBOX.22 11\r\nhello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("INBOX.22");
c.Pa.Size.ShouldBe(11);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// Data larger than expected size
pub = "PUB foo.bar 11\r\nhello world hello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldNotBeNull();
c.MsgBuf.ShouldBeNull();
}
// =====================================================================
// TestParsePubSizeOverflow — Go test ID 2603
// =====================================================================
[Fact]
public void ParsePubSizeOverflow_ReturnsError()
{
var c = DummyClient();
var h = DummyHandler();
var pub = Encoding.ASCII.GetBytes(
"PUB foo 3333333333333333333333333333333333333333333333333333333333333333\r\n");
ProtocolParser.Parse(c, h, pub).ShouldNotBeNull();
}
// =====================================================================
// TestParsePubArg — Go test ID 2604
// =====================================================================
[Theory]
[MemberData(nameof(PubArgTestCases))]
public void ProcessPub_ParsesArgsCorrectly(string arg, string subject, string reply, int size, string szb)
{
var c = DummyClient();
var err = ProtocolParser.ProcessPub(c, Encoding.ASCII.GetBytes(arg));
err.ShouldBeNull();
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe(subject);
if (string.IsNullOrEmpty(reply))
c.Pa.Reply.ShouldBeNull();
else
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe(reply);
Encoding.ASCII.GetString(c.Pa.SizeBytes!).ShouldBe(szb);
c.Pa.Size.ShouldBe(size);
}
public static TheoryData<string, string, string, int, string> PubArgTestCases => new()
{
{ "a 2", "a", "", 2, "2" },
{ "a 222", "a", "", 222, "222" },
{ "foo 22", "foo", "", 22, "22" },
{ " foo 22", "foo", "", 22, "22" },
{ "foo 22 ", "foo", "", 22, "22" },
{ "foo 22", "foo", "", 22, "22" },
{ " foo 22 ", "foo", "", 22, "22" },
{ " foo 22 ", "foo", "", 22, "22" },
{ "foo bar 22", "foo", "bar", 22, "22" },
{ " foo bar 22", "foo", "bar", 22, "22" },
{ "foo bar 22 ", "foo", "bar", 22, "22" },
{ "foo bar 22", "foo", "bar", 22, "22" },
{ " foo bar 22 ", "foo", "bar", 22, "22" },
{ " foo bar 22 ", "foo", "bar", 22, "22" },
{ " foo bar 2222 ", "foo", "bar", 2222, "2222" },
{ " foo 2222 ", "foo", "", 2222, "2222" },
{ "a\t2", "a", "", 2, "2" },
{ "a\t222", "a", "", 222, "222" },
{ "foo\t22", "foo", "", 22, "22" },
{ "\tfoo\t22", "foo", "", 22, "22" },
{ "foo\t22\t", "foo", "", 22, "22" },
{ "foo\t\t\t22", "foo", "", 22, "22" },
{ "\tfoo\t22\t", "foo", "", 22, "22" },
{ "\tfoo\t\t\t22\t", "foo", "", 22, "22" },
{ "foo\tbar\t22", "foo", "bar", 22, "22" },
{ "\tfoo\tbar\t22", "foo", "bar", 22, "22" },
{ "foo\tbar\t22\t", "foo", "bar", 22, "22" },
{ "foo\t\tbar\t\t22", "foo", "bar", 22, "22" },
{ "\tfoo\tbar\t22\t", "foo", "bar", 22, "22" },
{ "\t \tfoo\t \t \tbar\t \t22\t \t", "foo", "bar", 22, "22" },
{ "\t\tfoo\t\t\tbar\t\t2222\t\t", "foo", "bar", 2222, "2222" },
{ "\t \tfoo\t \t \t\t\t2222\t \t", "foo", "", 2222, "2222" },
};
// =====================================================================
// TestParsePubBadSize — Go test ID 2605
// =====================================================================
[Fact]
public void ProcessPub_BadSize_ReturnsError()
{
var c = DummyClient();
c.MaxPayload = 32768;
var err = ProtocolParser.ProcessPub(c, "foo 2222222222222222"u8.ToArray());
err.ShouldNotBeNull();
}
// =====================================================================
// TestParseHeaderPub — Go test ID 2606
// =====================================================================
[Fact]
public void ParseHeaderPub_ParsesSubjectReplyHdrSize()
{
var c = DummyClient();
c.HasHeaders = true;
var h = DummyHandler();
// Simple HPUB
var hpub = "HPUB foo 12 17\r\nname:derek\r\nHELLO\r"u8.ToArray();
ProtocolParser.Parse(c, h, hpub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo");
c.Pa.Reply.ShouldBeNull();
c.Pa.HeaderSize.ShouldBe(12);
Encoding.ASCII.GetString(c.Pa.HeaderBytes!).ShouldBe("12");
c.Pa.Size.ShouldBe(17);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// HPUB with reply
hpub = "HPUB foo INBOX.22 12 17\r\nname:derek\r\nHELLO\r"u8.ToArray();
ProtocolParser.Parse(c, h, hpub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("INBOX.22");
c.Pa.HeaderSize.ShouldBe(12);
Encoding.ASCII.GetString(c.Pa.HeaderBytes!).ShouldBe("12");
c.Pa.Size.ShouldBe(17);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// HPUB with hdr=0
hpub = "HPUB foo INBOX.22 0 5\r\nHELLO\r"u8.ToArray();
ProtocolParser.Parse(c, h, hpub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("INBOX.22");
c.Pa.HeaderSize.ShouldBe(0);
Encoding.ASCII.GetString(c.Pa.HeaderBytes!).ShouldBe("0");
c.Pa.Size.ShouldBe(5);
}
// =====================================================================
// TestParseHeaderPubArg — Go test ID 2607
// =====================================================================
[Theory]
[MemberData(nameof(HeaderPubArgTestCases))]
public void ProcessHeaderPub_ParsesArgsCorrectly(
string arg, string subject, string reply, int hdr, int size, string szb)
{
var c = DummyClient();
c.HasHeaders = true;
var err = ProtocolParser.ProcessHeaderPub(c, Encoding.ASCII.GetBytes(arg), null);
err.ShouldBeNull();
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe(subject);
if (string.IsNullOrEmpty(reply))
c.Pa.Reply.ShouldBeNull();
else
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe(reply);
Encoding.ASCII.GetString(c.Pa.SizeBytes!).ShouldBe(szb);
c.Pa.HeaderSize.ShouldBe(hdr);
c.Pa.Size.ShouldBe(size);
}
public static TheoryData<string, string, string, int, int, string> HeaderPubArgTestCases => new()
{
{ "a 2 4", "a", "", 2, 4, "4" },
{ "a 22 222", "a", "", 22, 222, "222" },
{ "foo 3 22", "foo", "", 3, 22, "22" },
{ " foo 1 22", "foo", "", 1, 22, "22" },
{ "foo 0 22 ", "foo", "", 0, 22, "22" },
{ "foo 0 22", "foo", "", 0, 22, "22" },
{ " foo 1 22 ", "foo", "", 1, 22, "22" },
{ " foo 3 22 ", "foo", "", 3, 22, "22" },
{ "foo bar 1 22", "foo", "bar", 1, 22, "22" },
{ " foo bar 11 22", "foo", "bar", 11, 22, "22" },
{ "foo bar 11 22 ", "foo", "bar", 11, 22, "22" },
{ "foo bar 11 22", "foo", "bar", 11, 22, "22" },
{ " foo bar 11 22 ", "foo", "bar", 11, 22, "22" },
{ " foo bar 11 22 ", "foo", "bar", 11, 22, "22" },
{ " foo bar 22 2222 ", "foo", "bar", 22, 2222, "2222" },
{ " foo 1 2222 ", "foo", "", 1, 2222, "2222" },
{ "a\t2\t22", "a", "", 2, 22, "22" },
{ "a\t2\t\t222", "a", "", 2, 222, "222" },
{ "foo\t2 22", "foo", "", 2, 22, "22" },
{ "\tfoo\t11\t 22", "foo", "", 11, 22, "22" },
{ "foo\t11\t22\t", "foo", "", 11, 22, "22" },
{ "foo\t\t\t11 22", "foo", "", 11, 22, "22" },
{ "\tfoo\t11\t \t 22\t", "foo", "", 11, 22, "22" },
{ "\tfoo\t\t\t11 22\t", "foo", "", 11, 22, "22" },
{ "foo\tbar\t2 22", "foo", "bar", 2, 22, "22" },
{ "\tfoo\tbar\t11\t22", "foo", "bar", 11, 22, "22" },
{ "foo\tbar\t11\t\t22\t ", "foo", "bar", 11, 22, "22" },
{ "foo\t\tbar\t\t11\t\t\t22", "foo", "bar", 11, 22, "22" },
{ "\tfoo\tbar\t11\t22\t", "foo", "bar", 11, 22, "22" },
{ "\t \tfoo\t \t \tbar\t \t11\t 22\t \t", "foo", "bar", 11, 22, "22" },
{ "\t\tfoo\t\t\tbar\t\t22\t\t\t2222\t\t", "foo", "bar", 22, 2222, "2222" },
{ "\t \tfoo\t \t \t\t\t11\t\t 2222\t \t", "foo", "", 11, 2222, "2222" },
};
// =====================================================================
// TestParseRoutedHeaderMsg — Go test ID 2608
// =====================================================================
[Fact]
public void ParseRoutedHeaderMsg_ParsesCorrectly()
{
var c = DummyRouteClient();
var h = DummyHandler();
// hdr > size should error
var pub = "HMSG $foo foo 10 8\r\nXXXhello\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldNotBeNull();
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// Simple HMSG
pub = "HMSG $foo foo 3 8\r\nXXXhello\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$foo");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo");
c.Pa.Reply.ShouldBeNull();
c.Pa.HeaderSize.ShouldBe(3);
c.Pa.Size.ShouldBe(8);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// HMSG with reply
pub = "HMSG $G foo.bar INBOX.22 3 14\r\nOK:hello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$G");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("INBOX.22");
c.Pa.HeaderSize.ShouldBe(3);
c.Pa.Size.ShouldBe(14);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// HMSG with + reply and queue
pub = "HMSG $G foo.bar + reply baz 3 14\r\nOK:hello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$G");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("reply");
c.Pa.Queues.ShouldNotBeNull();
c.Pa.Queues!.Count.ShouldBe(1);
Encoding.ASCII.GetString(c.Pa.Queues[0]).ShouldBe("baz");
c.Pa.HeaderSize.ShouldBe(3);
c.Pa.Size.ShouldBe(14);
// Clear snapshots
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// HMSG with | queue (no reply)
pub = "HMSG $G foo.bar | baz 3 14\r\nOK:hello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$G");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("");
c.Pa.Queues.ShouldNotBeNull();
c.Pa.Queues!.Count.ShouldBe(1);
Encoding.ASCII.GetString(c.Pa.Queues[0]).ShouldBe("baz");
c.Pa.HeaderSize.ShouldBe(3);
c.Pa.Size.ShouldBe(14);
}
// =====================================================================
// TestParseRouteMsg — Go test ID 2609
// =====================================================================
[Fact]
public void ParseRouteMsg_ParsesCorrectly()
{
var c = DummyRouteClient();
var h = DummyHandler();
// MSG from route should error (must use RMSG)
var pub = "MSG $foo foo 5\r\nhello\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldNotBeNull();
// Reset
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// RMSG simple
pub = "RMSG $foo foo 5\r\nhello\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$foo");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo");
c.Pa.Reply.ShouldBeNull();
c.Pa.Size.ShouldBe(5);
// Clear
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// RMSG with reply
pub = "RMSG $G foo.bar INBOX.22 11\r\nhello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$G");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("INBOX.22");
c.Pa.Size.ShouldBe(11);
// Clear
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// RMSG with + reply and queue
pub = "RMSG $G foo.bar + reply baz 11\r\nhello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$G");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("reply");
c.Pa.Queues.ShouldNotBeNull();
c.Pa.Queues!.Count.ShouldBe(1);
Encoding.ASCII.GetString(c.Pa.Queues[0]).ShouldBe("baz");
// Clear
c.ArgBuf = null; c.MsgBuf = null; c.State = ParserState.OpStart;
// RMSG with | queue (no reply)
pub = "RMSG $G foo.bar | baz 11\r\nhello world\r"u8.ToArray();
ProtocolParser.Parse(c, h, pub).ShouldBeNull();
c.State.ShouldBe(ParserState.MsgEndN);
Encoding.ASCII.GetString(c.Pa.Account!).ShouldBe("$G");
Encoding.ASCII.GetString(c.Pa.Subject!).ShouldBe("foo.bar");
Encoding.ASCII.GetString(c.Pa.Reply!).ShouldBe("");
c.Pa.Queues.ShouldNotBeNull();
c.Pa.Queues!.Count.ShouldBe(1);
Encoding.ASCII.GetString(c.Pa.Queues[0]).ShouldBe("baz");
}
// =====================================================================
// TestParseMsgSpace — Go test ID 2610
// =====================================================================
[Fact]
public void ParseMsgSpace_ErrorsCorrectly()
{
// MSG <SPC> from route should error
var c = DummyRouteClient();
var h = DummyHandler();
ProtocolParser.Parse(c, h, "MSG \r\n"u8.ToArray()).ShouldNotBeNull();
// M from client should error
c = DummyClient();
ProtocolParser.Parse(c, h, "M"u8.ToArray()).ShouldNotBeNull();
}
// =====================================================================
// TestShouldFail — Go test ID 2611
// =====================================================================
[Theory]
[MemberData(nameof(ShouldFailClientProtos))]
public void ShouldFail_ClientProtos(string proto)
{
var c = DummyClient();
var h = DummyHandler();
ProtocolParser.Parse(c, h, Encoding.ASCII.GetBytes(proto)).ShouldNotBeNull();
}
public static TheoryData<string> ShouldFailClientProtos => new()
{
"xxx",
"Px", "PIx", "PINx", " PING",
"POx", "PONx",
"+x", "+Ox",
"-x", "-Ex", "-ERx", "-ERRx",
"Cx", "COx", "CONx", "CONNx", "CONNEx", "CONNECx", "CONNECT \r\n",
"PUx", "PUB foo\r\n", "PUB \r\n", "PUB foo bar \r\n",
"PUB foo 2\r\nok \r\n", "PUB foo 2\r\nok\r \n",
"Sx", "SUx", "SUB\r\n", "SUB \r\n", "SUB foo\r\n",
"SUB foo bar baz 22\r\n",
"Ux", "UNx", "UNSx", "UNSUx", "UNSUBx", "UNSUBUNSUB 1\r\n", "UNSUB_2\r\n",
"UNSUB_UNSUB_UNSUB 2\r\n", "UNSUB_\t2\r\n", "UNSUB\r\n", "UNSUB \r\n",
"UNSUB \t \r\n",
"Ix", "INx", "INFx", "INFO \r\n",
};
[Theory]
[MemberData(nameof(ShouldFailRouterProtos))]
public void ShouldFail_RouterProtos(string proto)
{
var c = DummyClient();
c.Kind = ClientKind.Router;
var h = DummyHandler();
ProtocolParser.Parse(c, h, Encoding.ASCII.GetBytes(proto)).ShouldNotBeNull();
}
public static TheoryData<string> ShouldFailRouterProtos => new()
{
"Mx", "MSx", "MSGx", "MSG \r\n",
};
// =====================================================================
// TestProtoSnippet — Go test ID 2612
// =====================================================================
[Fact]
public void ProtoSnippet_MatchesGoOutput()
{
var sample = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"u8.ToArray();
var tests = new (int Start, string Expected)[]
{
(0, "\"abcdefghijklmnopqrstuvwxyzABCDEF\""),
(1, "\"bcdefghijklmnopqrstuvwxyzABCDEFG\""),
(2, "\"cdefghijklmnopqrstuvwxyzABCDEFGH\""),
(3, "\"defghijklmnopqrstuvwxyzABCDEFGHI\""),
(4, "\"efghijklmnopqrstuvwxyzABCDEFGHIJ\""),
(5, "\"fghijklmnopqrstuvwxyzABCDEFGHIJK\""),
(6, "\"ghijklmnopqrstuvwxyzABCDEFGHIJKL\""),
(7, "\"hijklmnopqrstuvwxyzABCDEFGHIJKLM\""),
(8, "\"ijklmnopqrstuvwxyzABCDEFGHIJKLMN\""),
(9, "\"jklmnopqrstuvwxyzABCDEFGHIJKLMNO\""),
(10, "\"klmnopqrstuvwxyzABCDEFGHIJKLMNOP\""),
(11, "\"lmnopqrstuvwxyzABCDEFGHIJKLMNOPQ\""),
(12, "\"mnopqrstuvwxyzABCDEFGHIJKLMNOPQR\""),
(13, "\"nopqrstuvwxyzABCDEFGHIJKLMNOPQRS\""),
(14, "\"opqrstuvwxyzABCDEFGHIJKLMNOPQRST\""),
(15, "\"pqrstuvwxyzABCDEFGHIJKLMNOPQRSTU\""),
(16, "\"qrstuvwxyzABCDEFGHIJKLMNOPQRSTUV\""),
(17, "\"rstuvwxyzABCDEFGHIJKLMNOPQRSTUVW\""),
(18, "\"stuvwxyzABCDEFGHIJKLMNOPQRSTUVWX\""),
(19, "\"tuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY\""),
(20, "\"uvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\""),
(21, "\"vwxyzABCDEFGHIJKLMNOPQRSTUVWXY\""),
(22, "\"wxyzABCDEFGHIJKLMNOPQRSTUVWXY\""),
(23, "\"xyzABCDEFGHIJKLMNOPQRSTUVWXY\""),
(24, "\"yzABCDEFGHIJKLMNOPQRSTUVWXY\""),
(25, "\"zABCDEFGHIJKLMNOPQRSTUVWXY\""),
(26, "\"ABCDEFGHIJKLMNOPQRSTUVWXY\""),
(27, "\"BCDEFGHIJKLMNOPQRSTUVWXY\""),
(28, "\"CDEFGHIJKLMNOPQRSTUVWXY\""),
(29, "\"DEFGHIJKLMNOPQRSTUVWXY\""),
(30, "\"EFGHIJKLMNOPQRSTUVWXY\""),
(31, "\"FGHIJKLMNOPQRSTUVWXY\""),
(32, "\"GHIJKLMNOPQRSTUVWXY\""),
(33, "\"HIJKLMNOPQRSTUVWXY\""),
(34, "\"IJKLMNOPQRSTUVWXY\""),
(35, "\"JKLMNOPQRSTUVWXY\""),
(36, "\"KLMNOPQRSTUVWXY\""),
(37, "\"LMNOPQRSTUVWXY\""),
(38, "\"MNOPQRSTUVWXY\""),
(39, "\"NOPQRSTUVWXY\""),
(40, "\"OPQRSTUVWXY\""),
(41, "\"PQRSTUVWXY\""),
(42, "\"QRSTUVWXY\""),
(43, "\"RSTUVWXY\""),
(44, "\"STUVWXY\""),
(45, "\"TUVWXY\""),
(46, "\"UVWXY\""),
(47, "\"VWXY\""),
(48, "\"WXY\""),
(49, "\"XY\""),
(50, "\"Y\""),
(51, "\"\""),
(52, "\"\""),
(53, "\"\""),
(54, "\"\""),
};
foreach (var (start, expected) in tests)
{
var got = ProtocolParser.ProtoSnippet(start, ServerConstants.ProtoSnippetSize, sample);
got.ShouldBe(expected, $"start={start}");
}
}
// =====================================================================
// TestParseOK — Go test ID 2613 (mapped from Go TestParseOK)
// =====================================================================
[Fact]
public void ParseOK_ByteByByte()
{
var c = DummyClient();
var h = DummyHandler();
c.State.ShouldBe(ParserState.OpStart);
var ok = "+OK\r\n"u8.ToArray();
ProtocolParser.Parse(c, h, ok[..1]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPlus);
ProtocolParser.Parse(c, h, ok[1..2]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPlusO);
ProtocolParser.Parse(c, h, ok[2..3]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPlusOk);
ProtocolParser.Parse(c, h, ok[3..4]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpPlusOk);
ProtocolParser.Parse(c, h, ok[4..5]).ShouldBeNull();
c.State.ShouldBe(ParserState.OpStart);
}
// =====================================================================
// TestMaxControlLine — Go test ID 2614
// =====================================================================
[Theory]
[InlineData(ClientKind.Client, true)]
[InlineData(ClientKind.Leaf, false)]
[InlineData(ClientKind.Router, false)]
[InlineData(ClientKind.Gateway, false)]
public void MaxControlLine_EnforcedForClientOnly(ClientKind kind, bool shouldFail)
{
var pub = "PUB foo.bar.baz 2\r\nok\r\n"u8.ToArray();
var c = new ParseContext
{
Kind = kind,
MaxControlLine = 8, // Very small limit
MaxPayload = -1,
};
var h = DummyHandler();
// For non-client kinds, we need to set up the OP appropriately
// Routes use RMSG not PUB, but PUB is fine for testing mcl enforcement
// since the state machine handles it the same way.
var err = ProtocolParser.Parse(c, h, pub);
if (shouldFail)
{
err.ShouldNotBeNull();
ErrorContextHelper.ErrorIs(err, ServerErrors.ErrMaxControlLine).ShouldBeTrue();
}
else
{
// Non-client kinds don't enforce max control line
err.ShouldBeNull();
}
}
// =====================================================================
// TestProtocolHandler — stub handler for tests
// =====================================================================
private sealed class TestProtocolHandler : IProtocolHandler
{
public bool IsMqtt => false;
public bool Trace => false;
public bool HasMappings => false;
public bool IsAwaitingAuth => false;
public bool TryRegisterNoAuthUser() => true; // Allow all
public bool IsGatewayInboundNotConnected => false;
public int PingCount { get; private set; }
public int PongCount { get; private set; }
public byte[]? ConnectArgs { get; private set; }
public Exception? ProcessConnect(byte[] arg) { ConnectArgs = arg; return null; }
public Exception? ProcessInfo(byte[] arg) => null;
public void ProcessPing() => PingCount++;
public void ProcessPong() => PongCount++;
public void ProcessErr(string arg) { }
public Exception? ProcessClientSub(byte[] arg) => null;
public Exception? ProcessClientUnsub(byte[] arg) => null;
public Exception? ProcessRemoteSub(byte[] arg, bool isLeaf) => null;
public Exception? ProcessRemoteUnsub(byte[] arg, bool isLeafUnsub) => null;
public Exception? ProcessGatewayRSub(byte[] arg) => null;
public Exception? ProcessGatewayRUnsub(byte[] arg) => null;
public Exception? ProcessLeafSub(byte[] arg) => null;
public Exception? ProcessLeafUnsub(byte[] arg) => null;
public Exception? ProcessAccountSub(byte[] arg) => null;
public void ProcessAccountUnsub(byte[] arg) { }
public void ProcessInboundMsg(byte[] msg) { }
public bool SelectMappedSubject() => false;
public void TraceInOp(string name, byte[]? arg) { }
public void TraceMsg(byte[] msg) { }
public void SendErr(string msg) { }
public void AuthViolation() { }
public void CloseConnection(int reason) { }
public string KindString() => "CLIENT";
}
}

Binary file not shown.

View File

@@ -1,6 +1,6 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 17:27:34 UTC
Generated: 2026-02-26 18:16:57 UTC
## Modules (12 total)
@@ -13,18 +13,18 @@ Generated: 2026-02-26 17:27:34 UTC
| Status | Count |
|--------|-------|
| complete | 467 |
| complete | 472 |
| n_a | 82 |
| not_started | 3031 |
| not_started | 3026 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 225 |
| complete | 242 |
| n_a | 82 |
| not_started | 2726 |
| not_started | 2709 |
| stub | 224 |
## Library Mappings (36 total)
@@ -36,4 +36,4 @@ Generated: 2026-02-26 17:27:34 UTC
## Overall Progress
**867/6942 items complete (12.5%)**
**889/6942 items complete (12.8%)**

39
reports/report_0a54d34.md Normal file
View File

@@ -0,0 +1,39 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 18:16:57 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 472 |
| n_a | 82 |
| not_started | 3026 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 242 |
| n_a | 82 |
| not_started | 2709 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**889/6942 items complete (12.8%)**