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:
@@ -194,6 +194,10 @@ After leaves are done, modules that depended only on those leaves become ready.
|
||||
Leaf utilities -> Protocol types -> Parser -> Connection handler -> Server
|
||||
```
|
||||
|
||||
### Server module session plan
|
||||
|
||||
The server module (~103K Go LOC, 3,394 features, 3,137 tests) is too large for a single pass. It has been broken into **23 sessions** with dependency ordering and sub-batching guidance. See [phase6sessions/readme.md](phase6sessions/readme.md) for the full session map, dependency graph, and execution instructions.
|
||||
|
||||
### Port tests alongside features
|
||||
|
||||
When porting a feature, also port its associated tests in the same pass. This provides immediate validation:
|
||||
|
||||
143
docs/plans/phases/phase6sessions/readme.md
Normal file
143
docs/plans/phases/phase6sessions/readme.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Phase 6 Sessions: Server Module Breakdown
|
||||
|
||||
The server module (module 8) contains **3,394 features**, **3,137 unit tests**, and **~103K Go LOC** across 64 source files. It has been split into **23 sessions** targeting ~5K Go LOC each, ordered by dependency (bottom-up).
|
||||
|
||||
## Session Map
|
||||
|
||||
| Session | Name | Go LOC | Features | Tests | Go Files |
|
||||
|---------|------|--------|----------|-------|----------|
|
||||
| [01](session-01.md) | Foundation Types | 626 | 46 | 17 | const, errors, errors_gen, proto, ring, rate_counter, sdm, nkey |
|
||||
| [02](session-02.md) | Utilities & Queues | 1,325 | 68 | 57 | util, ipqueue, sendq, scheduler, subject_transform |
|
||||
| [03](session-03.md) | Configuration & Options | 5,400 | 86 | 89 | opts |
|
||||
| [04](session-04.md) | Logging, Signals & Services | 534 | 34 | 27 | log, signal*, service* |
|
||||
| [05](session-05.md) | Subscription Index | 1,416 | 81 | 96 | sublist |
|
||||
| [06](session-06.md) | Auth & JWT | 2,196 | 43 | 131 | auth, auth_callout, jwt, ciphersuites |
|
||||
| [07](session-07.md) | Protocol Parser | 1,165 | 5 | 17 | parser |
|
||||
| [08](session-08.md) | Client Connection | 5,953 | 195 | 113 | client, client_proxyproto |
|
||||
| [09](session-09.md) | Server Core — Init & Config | ~1,950 | ~76 | ~20 | server.go (first half) |
|
||||
| [10](session-10.md) | Server Core — Runtime & Lifecycle | ~1,881 | ~98 | ~27 | server.go (second half) |
|
||||
| [11](session-11.md) | Accounts & Directory Store | 4,493 | 234 | 84 | accounts, dirstore |
|
||||
| [12](session-12.md) | Events, Monitoring & Tracing | 6,319 | 218 | 188 | events, monitor, monitor_sort_opts, msgtrace |
|
||||
| [13](session-13.md) | Configuration Reload | 2,085 | 89 | 73 | reload |
|
||||
| [14](session-14.md) | Routes | 2,988 | 57 | 70 | route |
|
||||
| [15](session-15.md) | Leaf Nodes | 3,091 | 71 | 120 | leafnode |
|
||||
| [16](session-16.md) | Gateways | 2,816 | 91 | 88 | gateway |
|
||||
| [17](session-17.md) | Store Interfaces & Memory Store | 2,879 | 135 | 58 | store, memstore, disk_avail* |
|
||||
| [18](session-18.md) | File Store | 11,421 | 312 | 249 | filestore |
|
||||
| [19](session-19.md) | JetStream Core | 9,504 | 374 | 406 | jetstream, jetstream_api, jetstream_errors*, jetstream_events, jetstream_versioning, jetstream_batching |
|
||||
| [20](session-20.md) | JetStream Cluster & Raft | 14,176 | 429 | 617 | raft, jetstream_cluster |
|
||||
| [21](session-21.md) | Streams & Consumers | 12,700 | 402 | 315 | stream, consumer |
|
||||
| [22](session-22.md) | MQTT | 4,758 | 153 | 162 | mqtt |
|
||||
| [23](session-23.md) | WebSocket & OCSP | 2,962 | 97 | 113 | websocket, ocsp, ocsp_peer, ocsp_responsecache |
|
||||
| | **Totals** | **~103K** | **3,394** | **3,137** | |
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
S01 Foundation
|
||||
├── S02 Utilities
|
||||
├── S03 Options
|
||||
├── S04 Logging
|
||||
├── S05 Sublist ← S02
|
||||
├── S06 Auth ← S03
|
||||
└── S07 Parser
|
||||
|
||||
S08 Client ← S02, S03, S05, S07
|
||||
|
||||
S09 Server Init ← S03, S04, S05, S06
|
||||
S10 Server Runtime ← S08, S09
|
||||
|
||||
S11 Accounts ← S02, S03, S05, S06
|
||||
S12 Events & Monitor ← S08, S09, S11
|
||||
S13 Reload ← S03, S09
|
||||
|
||||
S14 Routes ← S07, S08, S09
|
||||
S15 Leafnodes ← S07, S08, S09, S14
|
||||
S16 Gateways ← S07, S08, S09, S11, S14
|
||||
|
||||
S17 Store Interfaces ← S01, S02
|
||||
S18 FileStore ← S17
|
||||
S19 JetStream Core ← S08, S09, S11, S17
|
||||
S20 JetStream Cluster ← S14, S17, S19
|
||||
S21 Streams & Consumers ← S08, S09, S11, S17, S19
|
||||
|
||||
S22 MQTT ← S08, S09, S11, S17, S19
|
||||
S23 WebSocket & OCSP ← S08, S09
|
||||
```
|
||||
|
||||
## Multi-Sitting Sessions
|
||||
|
||||
Sessions 18, 19, 20, and 21 exceed the ~5K target and include sub-batching guidance in their individual files. Plan for 2-3 sittings each.
|
||||
|
||||
| Session | Go LOC | Recommended Sittings |
|
||||
|---------|--------|---------------------|
|
||||
| S18 File Store | 11,421 | 2-3 |
|
||||
| S19 JetStream Core | 9,504 | 2-3 |
|
||||
| S20 JetStream Cluster & Raft | 14,176 | 3-4 |
|
||||
| S21 Streams & Consumers | 12,700 | 2-3 |
|
||||
|
||||
## Execution Order
|
||||
|
||||
Sessions should be executed roughly in order (S01 → S23), but parallel tracks are possible:
|
||||
|
||||
**Track A (Core):** S01 → S02 → S03 → S04 → S05 → S07 → S08 → S09 → S10
|
||||
|
||||
**Track B (Auth/Accounts):** S06 → S11 (after S03, S05)
|
||||
|
||||
**Track C (Networking):** S14 → S15 → S16 (after S08, S09)
|
||||
|
||||
**Track D (Storage):** S17 → S18 (after S01, S02)
|
||||
|
||||
**Track E (JetStream):** S19 → S20 → S21 (after S09, S11, S17)
|
||||
|
||||
**Track F (Protocols):** S22 → S23 (after S08, S09, S19)
|
||||
|
||||
**Cross-cutting:** S12, S13 (after S09, S11)
|
||||
|
||||
## How to Use
|
||||
|
||||
### Starting point
|
||||
|
||||
Begin with **Session 01** (Foundation Types). It has no dependencies and everything else builds on it.
|
||||
|
||||
### Session loop
|
||||
|
||||
Repeat until all 23 sessions are complete:
|
||||
|
||||
1. **Pick the next session.** Work through sessions in numerical order (S01 → S23). The numbering follows the dependency graph, so each session's prerequisites are already done by the time you reach it. If you want to parallelise, check the dependency graph above — any session whose dependencies are all complete is eligible.
|
||||
|
||||
2. **Open a new Claude Code session.** Reference the session file:
|
||||
```
|
||||
Port session N per docs/plans/phases/phase6sessions/session-NN.md
|
||||
```
|
||||
|
||||
3. **Port features.** For each feature in the session:
|
||||
- Mark as `stub` in `porting.db`
|
||||
- Implement the .NET code referencing the Go source
|
||||
- Mark as `complete` in `porting.db`
|
||||
|
||||
4. **Port tests.** For each test listed in the session file:
|
||||
- Implement the xUnit test
|
||||
- Run it: `dotnet test --filter "FullyQualifiedName~ClassName"`
|
||||
- Mark as `complete` in `porting.db`
|
||||
|
||||
5. **Verify the build.** Run `dotnet build` and `dotnet test` to confirm nothing is broken.
|
||||
|
||||
6. **Commit.** Commit all changes with a message like `feat: port session NN — <session name>`.
|
||||
|
||||
7. **Check progress.**
|
||||
```bash
|
||||
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
|
||||
```
|
||||
|
||||
### Multi-sitting sessions
|
||||
|
||||
Sessions 18, 19, 20, and 21 are too large for a single sitting. Each session file contains sub-batching guidance (e.g., 18a, 18b, 18c). Commit after each sub-batch rather than waiting for the entire session.
|
||||
|
||||
### Completion
|
||||
|
||||
All 23 sessions are done when:
|
||||
- Every feature in module 8 is `complete` or `n/a`
|
||||
- Every unit test in module 8 is `complete` or `n/a`
|
||||
- `dotnet build` succeeds
|
||||
- `dotnet test` passes
|
||||
48
docs/plans/phases/phase6sessions/session-01.md
Normal file
48
docs/plans/phases/phase6sessions/session-01.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Session 01: Foundation Types
|
||||
|
||||
## Summary
|
||||
|
||||
Constants, error types, error catalog, protocol definitions, ring buffer, rate counter, stream distribution model, and NKey utilities. These are the leaf types with no internal dependencies — everything else builds on them.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/const.go | 2 | 582–583 | 18 |
|
||||
| server/errors.go | 15 | 833–847 | 92 |
|
||||
| server/errors_gen.go | 6 | 848–853 | 158 |
|
||||
| server/proto.go | 6 | 2593–2598 | 237 |
|
||||
| server/ring.go | 6 | 2889–2894 | 34 |
|
||||
| server/rate_counter.go | 3 | 2797–2799 | 34 |
|
||||
| server/sdm.go | 5 | 2966–2970 | 39 |
|
||||
| server/nkey.go | 3 | 2440–2442 | 14 |
|
||||
| **Total** | **46** | | **626** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `Constants` — server constants and version info
|
||||
- `ServerErrorCatalog` — generated error codes and messages
|
||||
- `Protocol` — NATS protocol string constants
|
||||
- `RingBuffer` — fixed-size circular buffer
|
||||
- `RateCounter` — sliding window rate measurement
|
||||
- `StreamDistributionModel` — stream distribution enum/types
|
||||
- `NkeyUser` — NKey authentication types
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/errors_test.go | 2 | 297–298 |
|
||||
| server/ring_test.go | 2 | 2794–2795 |
|
||||
| server/rate_counter_test.go | 1 | 2720 |
|
||||
| server/nkey_test.go | 9 | 2362–2370 |
|
||||
| server/trust_test.go | 3 | 3058–3060 |
|
||||
| **Total** | **17** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- None (leaf session)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/` — types and enums at root or `Internal/`
|
||||
42
docs/plans/phases/phase6sessions/session-02.md
Normal file
42
docs/plans/phases/phase6sessions/session-02.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Session 02: Utilities & Queues
|
||||
|
||||
## Summary
|
||||
|
||||
General utility functions, IP-based queue, send queue, task scheduler, and subject transform engine. These are infrastructure pieces used across the server.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/util.go | 21 | 3485–3505 | 244 |
|
||||
| server/ipqueue.go | 14 | 1354–1367 | 175 |
|
||||
| server/sendq.go | 3 | 2971–2973 | 76 |
|
||||
| server/scheduler.go | 14 | 2952–2965 | 260 |
|
||||
| server/subject_transform.go | 16 | 3388–3403 | 570 |
|
||||
| **Total** | **68** | | **1,325** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `ServerUtilities` — string/byte helpers, random, hashing
|
||||
- `IpQueue<T>` — lock-free concurrent queue with IP grouping
|
||||
- `SendQueue` — outbound message queue
|
||||
- `Scheduler` — time-based task scheduler
|
||||
- `SubjectTransform` — NATS subject rewriting/mapping engine
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/util_test.go | 13 | 3061–3073 |
|
||||
| server/ipqueue_test.go | 28 | 688–715 |
|
||||
| server/subject_transform_test.go | 4 | 2958–2961 |
|
||||
| server/split_test.go | 12 | 2929–2940 |
|
||||
| **Total** | **57** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Internal/`
|
||||
37
docs/plans/phases/phase6sessions/session-03.md
Normal file
37
docs/plans/phases/phase6sessions/session-03.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Session 03: Configuration & Options
|
||||
|
||||
## Summary
|
||||
|
||||
The server options/configuration system. Parses config files, command-line args, and environment variables into the `ServerOptions` struct. This is large (5.4K LOC) but self-contained.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/opts.go | 86 | 2502–2587 | 5,400 |
|
||||
| **Total** | **86** | | **5,400** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `ServerOptions` — all configuration properties, parsing, validation, and defaults
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/opts_test.go | 86 | 2512–2597 |
|
||||
| server/config_check_test.go | 3 | 271–273 |
|
||||
| **Total** | **89** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types — constants, errors)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs`
|
||||
|
||||
## Notes
|
||||
|
||||
- This is a large flat file. Consider splitting `ServerOptions` into partial classes by concern (TLS options, cluster options, JetStream options, etc.)
|
||||
- Many options have default values defined in `const.go` (Session 01)
|
||||
48
docs/plans/phases/phase6sessions/session-04.md
Normal file
48
docs/plans/phases/phase6sessions/session-04.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Session 04: Logging, Signals & Services
|
||||
|
||||
## Summary
|
||||
|
||||
Logging infrastructure, OS signal handling (Unix/Windows/WASM), and Windows service management. Small session — good opportunity to also address platform-specific abstractions.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/log.go | 18 | 2050–2067 | 207 |
|
||||
| server/signal.go | 5 | 3155–3159 | 156 |
|
||||
| server/signal_wasm.go | 2 | 3160–3161 | 6 |
|
||||
| server/signal_windows.go | 2 | 3162–3163 | 79 |
|
||||
| server/service.go | 2 | 3148–3149 | 7 |
|
||||
| server/service_windows.go | 5 | 3150–3154 | 79 |
|
||||
| **Total** | **34** | | **534** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `NatsLogger` (or logging integration) — server logging wrapper
|
||||
- `SignalHandler` — OS signal handling (SIGTERM, SIGHUP, etc.)
|
||||
- `ServiceManager` — Windows service lifecycle
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/log_test.go | 6 | 2017–2022 |
|
||||
| server/signal_test.go | 19 | 2910–2928 |
|
||||
| server/service_test.go | 1 | 2908 |
|
||||
| server/service_windows_test.go | 1 | 2909 |
|
||||
| **Total** | **27** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Internal/` (logging)
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server.Host/` (signal/service)
|
||||
|
||||
## Notes
|
||||
|
||||
- .NET uses `Microsoft.Extensions.Logging` + Serilog per standards
|
||||
- Windows service support maps to `Microsoft.Extensions.Hosting.WindowsServices`
|
||||
- Signal handling maps to `Console.CancelKeyPress` + `AppDomain.ProcessExit`
|
||||
40
docs/plans/phases/phase6sessions/session-05.md
Normal file
40
docs/plans/phases/phase6sessions/session-05.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Session 05: Subscription Index
|
||||
|
||||
## Summary
|
||||
|
||||
The subscription list (sublist) — a trie-based data structure for matching NATS subjects to subscriptions. Core to message routing performance.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/sublist.go | 81 | 3404–3484 | 1,416 |
|
||||
| **Total** | **81** | | **1,416** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `SubscriptionIndex` — trie-based subject matching
|
||||
- `SubscriptionIndexResult` — match result container
|
||||
- `SublistStats` — statistics for the subscription index
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/sublist_test.go | 96 | 2962–3057 |
|
||||
| **Total** | **96** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 02 (Utilities — subject parsing helpers)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Internal/DataStructures/SubscriptionIndex.cs`
|
||||
|
||||
## Notes
|
||||
|
||||
- Performance-critical: hot path for every message published
|
||||
- Use `ReadOnlySpan<byte>` for subject matching on hot paths
|
||||
- The existing `SubjectTree` (already ported in stree module) is different from this — sublist is the subscription matcher
|
||||
45
docs/plans/phases/phase6sessions/session-06.md
Normal file
45
docs/plans/phases/phase6sessions/session-06.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Session 06: Authentication & JWT
|
||||
|
||||
## Summary
|
||||
|
||||
Authentication handlers (user/pass, token, NKey, TLS cert), auth callout (external auth service), JWT processing, and cipher suite definitions.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/auth.go | 31 | 350–380 | 1,498 |
|
||||
| server/auth_callout.go | 3 | 381–383 | 456 |
|
||||
| server/jwt.go | 6 | 1973–1978 | 205 |
|
||||
| server/ciphersuites.go | 3 | 384–386 | 37 |
|
||||
| **Total** | **43** | | **2,196** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `AuthHandler` — authentication dispatch and credential checking
|
||||
- `AuthCallout` — external auth callout service
|
||||
- `JwtProcessor` — NATS JWT validation and claims extraction
|
||||
- `CipherSuites` — TLS cipher suite definitions
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/auth_test.go | 12 | 142–153 |
|
||||
| server/auth_callout_test.go | 31 | 111–141 |
|
||||
| server/jwt_test.go | 88 | 1809–1896 |
|
||||
| **Total** | **131** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types — errors, constants)
|
||||
- Session 03 (Configuration — ServerOptions for auth config)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Auth/`
|
||||
|
||||
## Notes
|
||||
|
||||
- Auth is already partially scaffolded from leaf modules (certidp, certstore, tpm)
|
||||
- JWT test file is large (88 tests) — may need careful batching within the session
|
||||
39
docs/plans/phases/phase6sessions/session-07.md
Normal file
39
docs/plans/phases/phase6sessions/session-07.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Session 07: Protocol Parser
|
||||
|
||||
## Summary
|
||||
|
||||
The NATS protocol parser — parses raw bytes from client connections into protocol operations (PUB, SUB, UNSUB, CONNECT, etc.). Extremely performance-critical.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/parser.go | 5 | 2588–2592 | 1,165 |
|
||||
| **Total** | **5** | | **1,165** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `ProtocolParser` — state-machine parser for NATS wire protocol
|
||||
- `ClientConnection` (partial — parser-related methods only)
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/parser_test.go | 17 | 2598–2614 |
|
||||
| **Total** | **17** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types — protocol constants, errors)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Protocol/`
|
||||
|
||||
## Notes
|
||||
|
||||
- Only 5 features but 1,165 LOC — these are large state-machine functions
|
||||
- Must use `ReadOnlySpan<byte>` and avoid allocations in the parse loop
|
||||
- The parser is called for every byte received — benchmark after porting
|
||||
- Consider using `System.IO.Pipelines` for buffer management
|
||||
49
docs/plans/phases/phase6sessions/session-08.md
Normal file
49
docs/plans/phases/phase6sessions/session-08.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Session 08: Client Connection
|
||||
|
||||
## Summary
|
||||
|
||||
The client connection handler — manages individual client TCP connections, message processing, subscription management, and client lifecycle. The largest single class in the server.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/client.go | 185 | 387–571 | 5,680 |
|
||||
| server/client_proxyproto.go | 10 | 572–581 | 273 |
|
||||
| **Total** | **195** | | **5,953** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `ClientConnection` — client state, read/write loops, publish, subscribe, unsubscribe
|
||||
- `ClientFlag` — client state flags
|
||||
- `ClientInfo` — client metadata
|
||||
- `ProxyProtocolAddress` — PROXY protocol v1/v2 parsing
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/client_test.go | 82 | 182–263 |
|
||||
| server/client_proxyproto_test.go | 23 | 159–181 |
|
||||
| server/closed_conns_test.go | 7 | 264–270 |
|
||||
| server/ping_test.go | 1 | 2615 |
|
||||
| **Total** | **113** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 02 (Utilities — queues)
|
||||
- Session 03 (Configuration — ServerOptions)
|
||||
- Session 05 (Subscription Index)
|
||||
- Session 07 (Protocol Parser)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs`
|
||||
|
||||
## Notes
|
||||
|
||||
- This is the core networking class — every connected client has one
|
||||
- Heavy use of `sync.Mutex` in Go → consider `lock` or `SemaphoreSlim`
|
||||
- Write coalescing and flush logic is performance-critical
|
||||
- May need partial class split: `ClientConnection.Read.cs`, `ClientConnection.Write.cs`, etc.
|
||||
52
docs/plans/phases/phase6sessions/session-09.md
Normal file
52
docs/plans/phases/phase6sessions/session-09.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Session 09: Server Core — Initialization & Configuration
|
||||
|
||||
## Summary
|
||||
|
||||
First half of server.go: server construction, validation, account configuration, resolver setup, trusted keys, and the `Start()` method. This is the server bootstrap path.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/server.go (lines 85–2575) | ~76 | 2974–3050 | ~1,950 |
|
||||
| **Total** | **~76** | | **~1,950** |
|
||||
|
||||
### Key Features
|
||||
|
||||
- `New`, `NewServer`, `NewServerFromConfig` — constructors
|
||||
- `validateOptions`, `validateCluster`, `validatePinnedCerts` — config validation
|
||||
- `configureAccounts`, `configureResolver`, `checkResolvePreloads` — account setup
|
||||
- `processTrustedKeys`, `initStampedTrustedKeys` — JWT trust chain
|
||||
- `Start` — main server startup (313 LOC)
|
||||
- Compression helpers (`selectCompressionMode`, `s2WriterOptions`, etc.)
|
||||
- Account lookup/register/update methods
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `NatsServer` (partial — initialization, configuration, accounts)
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/server_test.go (partial) | ~20 | 2866–2885 |
|
||||
| **Total** | **~20** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 03 (Configuration — ServerOptions)
|
||||
- Session 04 (Logging)
|
||||
- Session 05 (Subscription Index)
|
||||
- Session 06 (Authentication)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs` (partial class)
|
||||
- Consider: `NatsServer.Init.cs`, `NatsServer.Accounts.cs`
|
||||
|
||||
## Notes
|
||||
|
||||
- `Server.Start()` is 313 LOC — the single largest function. Port carefully.
|
||||
- Account configuration deeply intertwines with JWT and resolver subsystems
|
||||
- Many methods reference route, gateway, and leafnode structures (forward declarations needed)
|
||||
57
docs/plans/phases/phase6sessions/session-10.md
Normal file
57
docs/plans/phases/phase6sessions/session-10.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Session 10: Server Core — Runtime & Lifecycle
|
||||
|
||||
## Summary
|
||||
|
||||
Second half of server.go: accept loops, client creation, monitoring HTTP server, TLS handling, lame duck mode, shutdown, and runtime query methods.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/server.go (lines 2577–4782) | ~98 | 3051–3147 | ~1,881 |
|
||||
| **Total** | **~98** | | **~1,881** |
|
||||
|
||||
### Key Features
|
||||
|
||||
- `Shutdown` — graceful shutdown (172 LOC)
|
||||
- `AcceptLoop`, `acceptConnections` — TCP listener
|
||||
- `createClientEx` — client connection factory (305 LOC)
|
||||
- `startMonitoring`, `StartHTTPMonitoring` — HTTP monitoring server
|
||||
- `lameDuckMode`, `sendLDMToRoutes`, `sendLDMToClients` — lame duck
|
||||
- `readyForConnections`, `readyForListeners` — startup synchronization
|
||||
- Numerous `Num*` query methods (routes, clients, subscriptions, etc.)
|
||||
- `getConnectURLs`, `PortsInfo` — connection metadata
|
||||
- `removeClient`, `saveClosedClient` — client lifecycle
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `NatsServer` (partial — runtime, lifecycle, queries)
|
||||
- `CaptureHTTPServerLog` — HTTP log adapter
|
||||
- `TlsMixConn` — mixed TLS/plain connection
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/server_test.go (partial) | ~22 | 2886–2907 |
|
||||
| server/benchmark_publish_test.go | 1 | 154 |
|
||||
| server/core_benchmarks_test.go | 4 | 274–277 |
|
||||
| **Total** | **~27** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 09 (Server Core Part 1)
|
||||
- Session 08 (Client Connection)
|
||||
- Session 04 (Logging)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.cs` (partial class)
|
||||
- Consider: `NatsServer.Lifecycle.cs`, `NatsServer.Listeners.cs`
|
||||
|
||||
## Notes
|
||||
|
||||
- `createClientEx` is 305 LOC — second largest function in the file
|
||||
- `Shutdown` involves coordinating across all subsystems
|
||||
- Monitoring HTTP server maps to ASP.NET Core Kestrel or minimal API
|
||||
- Lame duck mode requires careful timer/signal coordination
|
||||
52
docs/plans/phases/phase6sessions/session-11.md
Normal file
52
docs/plans/phases/phase6sessions/session-11.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Session 11: Accounts & Directory Store
|
||||
|
||||
## Summary
|
||||
|
||||
Multi-tenancy account system and directory-based JWT store. Accounts manage per-tenant state including JetStream limits, imports/exports, and user authentication.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/accounts.go | 200 | 150–349 | 3,918 |
|
||||
| server/dirstore.go | 34 | 793–826 | 575 |
|
||||
| **Total** | **234** | | **4,493** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `Account` — per-tenant account with limits, imports, exports
|
||||
- `DirectoryAccountResolver` — file-system-based account resolver
|
||||
- `CacheDirAccountResolver` — caching resolver wrapper
|
||||
- `MemoryAccountResolver` — in-memory resolver
|
||||
- `UriAccountResolver` — HTTP-based resolver
|
||||
- `DirJwtStore` — JWT file storage
|
||||
- `DirectoryStore` — directory abstraction
|
||||
- `ExpirationTracker` — JWT expiration tracking
|
||||
- `LocalCache` — local account cache
|
||||
- `ServiceExport`, `ServiceImport`, `ServiceLatency` — service mesh types
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/accounts_test.go | 65 | 46–110 |
|
||||
| server/dirstore_test.go | 19 | 278–296 |
|
||||
| **Total** | **84** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 02 (Utilities)
|
||||
- Session 03 (Configuration)
|
||||
- Session 05 (Subscription Index)
|
||||
- Session 06 (Auth & JWT)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Accounts/`
|
||||
|
||||
## Notes
|
||||
|
||||
- `Account` is the 4th largest class (4.5K LOC across multiple Go files)
|
||||
- accounts.go alone has 200 features — will need methodical batching within the session
|
||||
- Account methods are spread across accounts.go, consumer.go, events.go, jetstream.go, etc. — this session covers only accounts.go features
|
||||
53
docs/plans/phases/phase6sessions/session-12.md
Normal file
53
docs/plans/phases/phase6sessions/session-12.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Session 12: Events, Monitoring & Message Tracing
|
||||
|
||||
## Summary
|
||||
|
||||
Server-side event system (system events, advisory messages), HTTP monitoring endpoints (varz, connz, routez, etc.), and message tracing infrastructure.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/events.go | 97 | 854–950 | 2,445 |
|
||||
| server/monitor.go | 70 | 2166–2235 | 3,257 |
|
||||
| server/monitor_sort_opts.go | 16 | 2236–2251 | 48 |
|
||||
| server/msgtrace.go | 35 | 2405–2439 | 569 |
|
||||
| **Total** | **218** | | **6,319** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `EventsHandler` — system event publishing
|
||||
- `MonitoringHandler` — HTTP monitoring endpoints
|
||||
- `ConnInfo`, `ClosedState` — connection monitoring types
|
||||
- `HealthZErrorType` — health check error types
|
||||
- `MsgTrace`, `MsgTraceEvent`, `MsgTraceEvents` — message tracing
|
||||
- `MessageTracer` — tracing engine
|
||||
- Various sort option types (16 types)
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/events_test.go | 52 | 299–350 |
|
||||
| server/monitor_test.go | 103 | 2064–2166 |
|
||||
| server/msgtrace_test.go | 33 | 2329–2361 |
|
||||
| **Total** | **188** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 08 (Client Connection)
|
||||
- Session 09 (Server Core Part 1)
|
||||
- Session 11 (Accounts)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Monitoring/`
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Events/`
|
||||
|
||||
## Notes
|
||||
|
||||
- Monitor endpoints map to ASP.NET Core minimal API or controller endpoints
|
||||
- Events system uses internal pub/sub — publishes to `$SYS.*` subjects
|
||||
- This is a larger session (~6.3K LOC) but the code is relatively straightforward
|
||||
- Monitor has 103 tests — allocate time accordingly
|
||||
39
docs/plans/phases/phase6sessions/session-13.md
Normal file
39
docs/plans/phases/phase6sessions/session-13.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Session 13: Configuration Reload
|
||||
|
||||
## Summary
|
||||
|
||||
Hot-reload system for server configuration. Detects config changes and applies them without restarting the server. Each option type has a reload handler.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/reload.go | 89 | 2800–2888 | 2,085 |
|
||||
| **Total** | **89** | | **2,085** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `ConfigReloader` — reload orchestrator
|
||||
- 50+ individual option reload types (e.g., `AuthOption`, `TlsOption`, `ClusterOption`, `JetStreamOption`, etc.)
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/reload_test.go | 73 | 2721–2793 |
|
||||
| **Total** | **73** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 03 (Configuration — ServerOptions)
|
||||
- Session 09 (Server Core Part 1)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/ConfigReloader.cs`
|
||||
|
||||
## Notes
|
||||
|
||||
- Many small reload option types — consider using a single file with nested classes or a separate `Reload/` folder
|
||||
- Each option type implements a common interface for diff/apply pattern
|
||||
- 73 tests cover each option type's reload behavior
|
||||
41
docs/plans/phases/phase6sessions/session-14.md
Normal file
41
docs/plans/phases/phase6sessions/session-14.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Session 14: Routes
|
||||
|
||||
## Summary
|
||||
|
||||
Inter-server routing — how NATS servers form a full mesh cluster and route messages between nodes.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/route.go | 57 | 2895–2951 | 2,988 |
|
||||
| **Total** | **57** | | **2,988** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `RouteHandler` — route connection management
|
||||
- `ClientConnection` (partial — route-specific methods, 25 features from client.go already counted in S08)
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/routes_test.go | 70 | 2796–2865 |
|
||||
| **Total** | **70** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 07 (Protocol Parser)
|
||||
- Session 08 (Client Connection)
|
||||
- Session 09 (Server Core Part 1)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Routing/`
|
||||
|
||||
## Notes
|
||||
|
||||
- Route connections are `ClientConnection` instances with special handling
|
||||
- Protocol includes route-specific INFO, SUB, UNSUB, MSG operations
|
||||
- Cluster gossip and route solicitation logic lives here
|
||||
45
docs/plans/phases/phase6sessions/session-15.md
Normal file
45
docs/plans/phases/phase6sessions/session-15.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Session 15: Leaf Nodes
|
||||
|
||||
## Summary
|
||||
|
||||
Leaf node connections — lightweight connections from edge servers to hub servers. Simpler than full routes but with subject interest propagation.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/leafnode.go | 71 | 1979–2049 | 3,091 |
|
||||
| **Total** | **71** | | **3,091** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `LeafNodeHandler` — leaf node connection management
|
||||
- `LeafNodeCfg` — leaf node configuration
|
||||
- `LeafNodeOption` — leaf node reload option
|
||||
- `ClientConnection` (partial — leafnode-specific methods)
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/leafnode_test.go | 111 | 1906–2016 |
|
||||
| server/leafnode_proxy_test.go | 9 | 1897–1905 |
|
||||
| **Total** | **120** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 07 (Protocol Parser)
|
||||
- Session 08 (Client Connection)
|
||||
- Session 09 (Server Core Part 1)
|
||||
- Session 14 (Routes — shared routing infrastructure)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/LeafNode/`
|
||||
|
||||
## Notes
|
||||
|
||||
- 111 + 9 = 120 tests — this is a test-heavy session
|
||||
- Leaf nodes support TLS, auth, and subject deny lists
|
||||
- WebSocket transport for leaf nodes adds complexity
|
||||
47
docs/plans/phases/phase6sessions/session-16.md
Normal file
47
docs/plans/phases/phase6sessions/session-16.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Session 16: Gateways
|
||||
|
||||
## Summary
|
||||
|
||||
Gateway connections — inter-cluster message routing. Gateways enable NATS super-clusters where messages flow between independent clusters.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/gateway.go | 91 | 1263–1353 | 2,816 |
|
||||
| **Total** | **91** | | **2,816** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `GatewayHandler` — gateway connection management
|
||||
- `GatewayCfg` — gateway configuration
|
||||
- `ServerGateway` — per-server gateway state
|
||||
- `GatewayInterestMode` — interest/optimistic mode tracking
|
||||
- `GwReplyMapping` — reply-to subject mapping for gateways
|
||||
- `ClientConnection` (partial — gateway-specific methods)
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/gateway_test.go | 88 | 600–687 |
|
||||
| **Total** | **88** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 07 (Protocol Parser)
|
||||
- Session 08 (Client Connection)
|
||||
- Session 09 (Server Core Part 1)
|
||||
- Session 11 (Accounts — for interest propagation)
|
||||
- Session 14 (Routes)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Gateway/`
|
||||
|
||||
## Notes
|
||||
|
||||
- Gateway protocol has optimistic and interest-only modes
|
||||
- Account-aware interest propagation is complex
|
||||
- 88 tests — thorough coverage of gateway scenarios
|
||||
53
docs/plans/phases/phase6sessions/session-17.md
Normal file
53
docs/plans/phases/phase6sessions/session-17.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Session 17: Store Interfaces & Memory Store
|
||||
|
||||
## Summary
|
||||
|
||||
Storage abstraction layer (interfaces for streams and consumers) and the in-memory storage implementation. Also includes disk availability checks.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/store.go | 31 | 3164–3194 | 391 |
|
||||
| server/memstore.go | 98 | 2068–2165 | 2,434 |
|
||||
| server/disk_avail.go | 1 | 827 | 15 |
|
||||
| server/disk_avail_netbsd.go | 1 | 828 | 3 |
|
||||
| server/disk_avail_openbsd.go | 1 | 829 | 15 |
|
||||
| server/disk_avail_solaris.go | 1 | 830 | 15 |
|
||||
| server/disk_avail_wasm.go | 1 | 831 | 3 |
|
||||
| server/disk_avail_windows.go | 1 | 832 | 3 |
|
||||
| **Total** | **135** | | **2,879** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `StorageEngine` — storage interface definitions (`StreamStore`, `ConsumerStore`)
|
||||
- `StoreMsg` — stored message type
|
||||
- `StorageType`, `StoreCipher`, `StoreCompression` — storage enums
|
||||
- `DeleteBlocks`, `DeleteRange`, `DeleteSlice` — deletion types
|
||||
- `JetStreamMemoryStore` — in-memory stream store
|
||||
- `ConsumerMemStore` — in-memory consumer store
|
||||
- `DiskAvailability` — disk space checker (platform-specific)
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/store_test.go | 17 | 2941–2957 |
|
||||
| server/memstore_test.go | 41 | 2023–2063 |
|
||||
| **Total** | **58** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 02 (Utilities)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/Storage/`
|
||||
|
||||
## Notes
|
||||
|
||||
- Store interfaces define the contract for both memory and file stores
|
||||
- MemStore is simpler than FileStore — good to port first as a reference implementation
|
||||
- Disk availability uses platform-specific syscalls — map to `DriveInfo` in .NET
|
||||
- Most disk_avail variants can be N/A (use .NET cross-platform API instead)
|
||||
50
docs/plans/phases/phase6sessions/session-18.md
Normal file
50
docs/plans/phases/phase6sessions/session-18.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Session 18: File Store
|
||||
|
||||
## Summary
|
||||
|
||||
The persistent file-based storage engine for JetStream. Handles message persistence, compaction, encryption, compression, and recovery. This is the largest single-file session.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/filestore.go | 312 | 951–1262 | 11,421 |
|
||||
| **Total** | **312** | | **11,421** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `JetStreamFileStore` — file-based stream store (174 features, 7,255 LOC)
|
||||
- `MessageBlock` — individual message block on disk (95 features, 3,314 LOC)
|
||||
- `ConsumerFileStore` — file-based consumer store (33 features, 700 LOC)
|
||||
- `CompressionInfo` — compression metadata
|
||||
- `ErrBadMsg` — bad message error type
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/filestore_test.go | 249 | 351–599 |
|
||||
| **Total** | **249** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 02 (Utilities)
|
||||
- Session 17 (Store Interfaces)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/Storage/`
|
||||
|
||||
## Notes
|
||||
|
||||
- **This is a multi-sitting session** — 11.4K Go LOC and 249 tests
|
||||
- Suggested sub-batching:
|
||||
- **18a**: `MessageBlock` (95 features, 3.3K LOC) — the on-disk block format
|
||||
- **18b**: `JetStreamFileStore` core (load, store, recover, compact) — ~90 features
|
||||
- **18c**: `JetStreamFileStore` remaining (snapshots, encryption, purge) — ~84 features
|
||||
- **18d**: `ConsumerFileStore` (33 features, 700 LOC)
|
||||
- **18e**: Tests (249 tests)
|
||||
- File I/O should use `FileStream` with `RandomAccess` APIs for .NET 10
|
||||
- Encryption maps to `System.Security.Cryptography`
|
||||
- S2/Snappy compression maps to existing NuGet packages
|
||||
67
docs/plans/phases/phase6sessions/session-19.md
Normal file
67
docs/plans/phases/phase6sessions/session-19.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Session 19: JetStream Core
|
||||
|
||||
## Summary
|
||||
|
||||
JetStream engine core — initialization, API handlers, error definitions, event types, versioning, and batching. The central JetStream coordination layer.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/jetstream.go | 84 | 1368–1451 | 2,481 |
|
||||
| server/jetstream_api.go | 56 | 1452–1507 | 4,269 |
|
||||
| server/jetstream_errors.go | 5 | 1751–1755 | 62 |
|
||||
| server/jetstream_errors_generated.go | 203 | 1756–1958 | 1,924 |
|
||||
| server/jetstream_events.go | 1 | 1959 | 25 |
|
||||
| server/jetstream_versioning.go | 13 | 1960–1972 | 175 |
|
||||
| server/jetstream_batching.go | 12 | 1508–1519 | 568 |
|
||||
| **Total** | **374** | | **9,504** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `JetStreamEngine` — JetStream lifecycle, enable/disable, account tracking
|
||||
- `JetStreamApi` — REST-like API handlers for stream/consumer CRUD
|
||||
- `JetStreamErrors` — error code registry (208 entries)
|
||||
- `JetStreamEvents` — advisory event types
|
||||
- `JetStreamVersioning` — feature version compatibility
|
||||
- `JetStreamBatching` — batch message processing
|
||||
- `JsAccount` — per-account JetStream state
|
||||
- `JsOutQ` — JetStream output queue
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/jetstream_test.go | 320 | 1466–1785 |
|
||||
| server/jetstream_errors_test.go | 4 | 1381–1384 |
|
||||
| server/jetstream_versioning_test.go | 18 | 1791–1808 |
|
||||
| server/jetstream_batching_test.go | 29 | 716–744 |
|
||||
| server/jetstream_jwt_test.go | 18 | 1385–1402 |
|
||||
| server/jetstream_tpm_test.go | 5 | 1786–1790 |
|
||||
| server/jetstream_benchmark_test.go | 12 | 745–756 |
|
||||
| **Total** | **406** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 03 (Configuration)
|
||||
- Session 08 (Client Connection)
|
||||
- Session 09 (Server Core Part 1)
|
||||
- Session 11 (Accounts)
|
||||
- Session 17 (Store Interfaces)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/`
|
||||
|
||||
## Notes
|
||||
|
||||
- **This is a multi-sitting session** — 9.5K Go LOC and 406 tests
|
||||
- JetStream errors generated file is 203 features but mostly boilerplate error codes
|
||||
- jetstream_test.go has 320 tests — the largest test file
|
||||
- Suggested sub-batching:
|
||||
- **19a**: Error definitions and events (209 features, 2K LOC) — mostly mechanical
|
||||
- **19b**: JetStream engine core (84 features, 2.5K LOC)
|
||||
- **19c**: JetStream API (56 features, 4.3K LOC)
|
||||
- **19d**: Versioning + batching (25 features, 743 LOC)
|
||||
- **19e**: Tests (406 tests, batched by test file)
|
||||
70
docs/plans/phases/phase6sessions/session-20.md
Normal file
70
docs/plans/phases/phase6sessions/session-20.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Session 20: JetStream Cluster & Raft
|
||||
|
||||
## Summary
|
||||
|
||||
Raft consensus algorithm implementation and JetStream clustering — how streams and consumers are replicated across server nodes.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/raft.go | 198 | 2599–2796 | 4,078 |
|
||||
| server/jetstream_cluster.go | 231 | 1520–1750 | 10,098 |
|
||||
| **Total** | **429** | | **14,176** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `RaftNode` — Raft consensus implementation (169 features)
|
||||
- `AppendEntry`, `AppendEntryResponse` — Raft log entries
|
||||
- `Checkpoint` — Raft snapshots
|
||||
- `CommittedEntry`, `Entry`, `EntryType` — entry types
|
||||
- `VoteRequest`, `VoteResponse`, `RaftState` — election types
|
||||
- `RaftGroup` — Raft group configuration
|
||||
- `JetStreamCluster` — cluster-wide JetStream coordination (51 features)
|
||||
- `Consumer` (cluster) — consumer assignment tracking (7 features)
|
||||
- `ConsumerAssignment` — consumer placement
|
||||
- `StreamAssignment`, `UnsupportedStreamAssignment` — stream placement
|
||||
- Plus ~69 `JetStreamEngine` methods for cluster operations
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/raft_test.go | 104 | 2616–2719 |
|
||||
| server/jetstream_cluster_1_test.go | 151 | 757–907 |
|
||||
| server/jetstream_cluster_2_test.go | 123 | 908–1030 |
|
||||
| server/jetstream_cluster_3_test.go | 97 | 1031–1127 |
|
||||
| server/jetstream_cluster_4_test.go | 85 | 1128–1212 |
|
||||
| server/jetstream_cluster_long_test.go | 7 | 1213–1219 |
|
||||
| server/jetstream_super_cluster_test.go | 47 | 1419–1465 |
|
||||
| server/jetstream_meta_benchmark_test.go | 2 | 1416–1417 |
|
||||
| server/jetstream_sourcing_scaling_test.go | 1 | 1418 |
|
||||
| **Total** | **617** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 08 (Client Connection)
|
||||
- Session 09 (Server Core Part 1)
|
||||
- Session 14 (Routes)
|
||||
- Session 17 (Store Interfaces)
|
||||
- Session 19 (JetStream Core)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/Cluster/`
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Raft/`
|
||||
|
||||
## Notes
|
||||
|
||||
- **This is a multi-sitting session** — 14.2K Go LOC and 617 tests (the largest session)
|
||||
- Suggested sub-batching:
|
||||
- **20a**: Raft types and election (entries, votes, state — ~30 features)
|
||||
- **20b**: Raft core (log replication, append, commit — ~85 features)
|
||||
- **20c**: Raft remaining (snapshots, checkpoints, recovery — ~83 features)
|
||||
- **20d**: JetStream cluster types and assignments (~30 features)
|
||||
- **20e**: JetStream cluster operations Part 1 (~130 features)
|
||||
- **20f**: JetStream cluster operations Part 2 (~71 features)
|
||||
- **20g**: Tests (617 tests, batched by test file)
|
||||
- Raft is the most algorithmically complex code in the server
|
||||
- Cluster tests often require multi-server setups — integration test candidates
|
||||
60
docs/plans/phases/phase6sessions/session-21.md
Normal file
60
docs/plans/phases/phase6sessions/session-21.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Session 21: Streams & Consumers
|
||||
|
||||
## Summary
|
||||
|
||||
Stream and consumer implementations — the core JetStream data plane. Streams store messages; consumers track delivery state and manage acknowledgments.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/stream.go | 193 | 3195–3387 | 6,980 |
|
||||
| server/consumer.go | 209 | 584–792 | 5,720 |
|
||||
| **Total** | **402** | | **12,700** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `NatsStream` — stream lifecycle, message ingestion, purge, snapshots (193 features)
|
||||
- `NatsConsumer` — consumer lifecycle, delivery, ack, nak, redelivery (174 features)
|
||||
- `ConsumerAction`, `ConsumerConfig`, `AckPolicy`, `DeliverPolicy`, `ReplayPolicy` — consumer types
|
||||
- `StreamConfig`, `StreamSource`, `ExternalStream` — stream types
|
||||
- `PriorityPolicy`, `RetentionPolicy`, `DiscardPolicy`, `PersistModeType` — policy enums
|
||||
- `WaitQueue`, `WaitingRequest`, `WaitingDelivery` — consumer wait types
|
||||
- `JSPubAckResponse`, `PubMsg`, `JsPubMsg`, `InMsg`, `CMsg` — message types
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/jetstream_consumer_test.go | 161 | 1220–1380 |
|
||||
| server/jetstream_leafnode_test.go | 13 | 1403–1415 |
|
||||
| server/norace_1_test.go | 100 | 2371–2470 |
|
||||
| server/norace_2_test.go | 41 | 2471–2511 |
|
||||
| **Total** | **315** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 02 (Utilities)
|
||||
- Session 08 (Client Connection)
|
||||
- Session 09 (Server Core Part 1)
|
||||
- Session 11 (Accounts)
|
||||
- Session 17 (Store Interfaces)
|
||||
- Session 19 (JetStream Core)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/JetStream/`
|
||||
|
||||
## Notes
|
||||
|
||||
- **This is a multi-sitting session** — 12.7K Go LOC and 315 tests
|
||||
- Suggested sub-batching:
|
||||
- **21a**: Stream/consumer types and enums (~40 features, ~500 LOC)
|
||||
- **21b**: NatsStream core (create, delete, purge — ~95 features)
|
||||
- **21c**: NatsStream remaining (snapshots, sources, mirrors — ~98 features)
|
||||
- **21d**: NatsConsumer core (create, deliver, ack — ~90 features)
|
||||
- **21e**: NatsConsumer remaining (redelivery, pull, push — ~84 features)
|
||||
- **21f**: Tests (315 tests)
|
||||
- `norace_*_test.go` files contain tests that must run without the Go race detector — these may have concurrency timing sensitivities
|
||||
- Consumer pull/push patterns need careful async design in C#
|
||||
51
docs/plans/phases/phase6sessions/session-22.md
Normal file
51
docs/plans/phases/phase6sessions/session-22.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Session 22: MQTT
|
||||
|
||||
## Summary
|
||||
|
||||
MQTT 3.1.1/5.0 protocol adapter — allows MQTT clients to connect to NATS and interact with JetStream for persistence.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/mqtt.go | 153 | 2252–2404 | 4,758 |
|
||||
| **Total** | **153** | | **4,758** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `MqttHandler` — MQTT protocol handler (35 features)
|
||||
- `MqttAccountSessionManager` — per-account MQTT session tracking (26 features)
|
||||
- `MqttSession` — individual MQTT session state (15 features)
|
||||
- `MqttJetStreamAdapter` — bridges MQTT to JetStream (22 features)
|
||||
- `MqttReader` — MQTT packet reader (8 features)
|
||||
- `MqttWriter` — MQTT packet writer (5 features)
|
||||
- Various MQTT reload options
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/mqtt_test.go | 159 | 2170–2328 |
|
||||
| server/mqtt_ex_test_test.go | 2 | 2168–2169 |
|
||||
| server/mqtt_ex_bench_test.go | 1 | 2167 |
|
||||
| **Total** | **162** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 08 (Client Connection)
|
||||
- Session 09 (Server Core Part 1)
|
||||
- Session 11 (Accounts)
|
||||
- Session 17 (Store Interfaces)
|
||||
- Session 19 (JetStream Core)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/`
|
||||
|
||||
## Notes
|
||||
|
||||
- MQTT is a self-contained protocol layer — could potentially be a separate assembly
|
||||
- 159 MQTT tests cover connection, subscribe, publish, QoS levels, sessions, retained messages
|
||||
- MQTT ↔ JetStream bridging is the most complex part
|
||||
- Consider using `System.IO.Pipelines` for MQTT packet parsing
|
||||
52
docs/plans/phases/phase6sessions/session-23.md
Normal file
52
docs/plans/phases/phase6sessions/session-23.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Session 23: WebSocket & OCSP
|
||||
|
||||
## Summary
|
||||
|
||||
WebSocket transport layer (allows browser clients to connect via WebSocket) and OCSP certificate stapling/checking infrastructure.
|
||||
|
||||
## Scope
|
||||
|
||||
| Go File | Features | Feature IDs | Go LOC |
|
||||
|---------|----------|-------------|--------|
|
||||
| server/websocket.go | 38 | 3506–3543 | 1,265 |
|
||||
| server/ocsp.go | 20 | 2443–2462 | 880 |
|
||||
| server/ocsp_peer.go | 9 | 2463–2471 | 356 |
|
||||
| server/ocsp_responsecache.go | 30 | 2472–2501 | 461 |
|
||||
| **Total** | **97** | | **2,962** |
|
||||
|
||||
## .NET Classes
|
||||
|
||||
- `WebSocketHandler` — WebSocket upgrade and frame handling
|
||||
- `WsReadInfo` — WebSocket read state
|
||||
- `SrvWebsocket` — WebSocket server configuration
|
||||
- `OcspHandler` — OCSP stapling orchestrator
|
||||
- `OCSPMonitor` — background OCSP response refresher
|
||||
- `NoOpCache` — no-op OCSP cache implementation
|
||||
|
||||
## Test Files
|
||||
|
||||
| Test File | Tests | Test IDs |
|
||||
|-----------|-------|----------|
|
||||
| server/websocket_test.go | 109 | 3074–3182 |
|
||||
| server/certstore_windows_test.go | 4 | 155–158 |
|
||||
| **Total** | **113** | |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Session 01 (Foundation Types)
|
||||
- Session 08 (Client Connection)
|
||||
- Session 09 (Server Core Part 1)
|
||||
- Leaf module: certidp (already complete)
|
||||
- Leaf module: certstore (already complete)
|
||||
|
||||
## .NET Target Location
|
||||
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/WebSocket/`
|
||||
- `dotnet/src/ZB.MOM.NatsNet.Server/Auth/Ocsp/`
|
||||
|
||||
## Notes
|
||||
|
||||
- WebSocket maps to ASP.NET Core WebSocket middleware or `System.Net.WebSockets`
|
||||
- OCSP integrates with the already-ported certidp and certstore modules
|
||||
- WebSocket test file has 109 tests — covers masking, framing, compression, upgrade
|
||||
- OCSP response cache has 30 features — manage certificate stapling lifecycle
|
||||
BIN
dotnet/porting.db
Normal file
BIN
dotnet/porting.db
Normal file
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
namespace ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Error and debug message constants for the OCSP peer identity provider.
|
||||
/// Mirrors certidp/messages.go.
|
||||
/// </summary>
|
||||
public static class OcspMessages
|
||||
{
|
||||
// Returned errors
|
||||
public const string ErrIllegalPeerOptsConfig = "expected map to define OCSP peer options, got [{0}]";
|
||||
public const string ErrIllegalCacheOptsConfig = "expected map to define OCSP peer cache options, got [{0}]";
|
||||
public const string ErrParsingPeerOptFieldGeneric = "error parsing tls peer config, unknown field [\"{0}\"]";
|
||||
public const string ErrParsingPeerOptFieldTypeConversion = "error parsing tls peer config, conversion error: {0}";
|
||||
public const string ErrParsingCacheOptFieldTypeConversion = "error parsing OCSP peer cache config, conversion error: {0}";
|
||||
public const string ErrUnableToPlugTLSEmptyConfig = "unable to plug TLS verify connection, config is nil";
|
||||
public const string ErrMTLSRequired = "OCSP peer verification for client connections requires TLS verify (mTLS) to be enabled";
|
||||
public const string ErrUnableToPlugTLSClient = "unable to register client OCSP verification";
|
||||
public const string ErrUnableToPlugTLSServer = "unable to register server OCSP verification";
|
||||
public const string ErrCannotWriteCompressed = "error writing to compression writer: {0}";
|
||||
public const string ErrCannotReadCompressed = "error reading compression reader: {0}";
|
||||
public const string ErrTruncatedWrite = "short write on body ({0} != {1})";
|
||||
public const string ErrCannotCloseWriter = "error closing compression writer: {0}";
|
||||
public const string ErrParsingCacheOptFieldGeneric = "error parsing OCSP peer cache config, unknown field [\"{0}\"]";
|
||||
public const string ErrUnknownCacheType = "error parsing OCSP peer cache config, unknown type [{0}]";
|
||||
public const string ErrInvalidChainlink = "invalid chain link";
|
||||
public const string ErrBadResponderHTTPStatus = "bad OCSP responder http status: [{0}]";
|
||||
public const string ErrNoAvailOCSPServers = "no available OCSP servers";
|
||||
public const string ErrFailedWithAllRequests = "exhausted OCSP responders: {0}";
|
||||
|
||||
// Direct logged errors
|
||||
public const string ErrLoadCacheFail = "Unable to load OCSP peer cache: {0}";
|
||||
public const string ErrSaveCacheFail = "Unable to save OCSP peer cache: {0}";
|
||||
public const string ErrBadCacheTypeConfig = "Unimplemented OCSP peer cache type [{0}]";
|
||||
public const string ErrResponseCompressFail = "Unable to compress OCSP response for key [{0}]: {1}";
|
||||
public const string ErrResponseDecompressFail = "Unable to decompress OCSP response for key [{0}]: {1}";
|
||||
public const string ErrPeerEmptyNoEvent = "Peer certificate is nil, cannot send OCSP peer reject event";
|
||||
public const string ErrPeerEmptyAutoReject = "Peer certificate is nil, rejecting OCSP peer";
|
||||
|
||||
// Debug messages
|
||||
public const string DbgPlugTLSForKind = "Plugging TLS OCSP peer for [{0}]";
|
||||
public const string DbgNumServerChains = "Peer OCSP enabled: {0} TLS server chain(s) will be evaluated";
|
||||
public const string DbgNumClientChains = "Peer OCSP enabled: {0} TLS client chain(s) will be evaluated";
|
||||
public const string DbgLinksInChain = "Chain [{0}]: {1} total link(s)";
|
||||
public const string DbgSelfSignedValid = "Chain [{0}] is self-signed, thus peer is valid";
|
||||
public const string DbgValidNonOCSPChain = "Chain [{0}] has no OCSP eligible links, thus peer is valid";
|
||||
public const string DbgChainIsOCSPEligible = "Chain [{0}] has {1} OCSP eligible link(s)";
|
||||
public const string DbgChainIsOCSPValid = "Chain [{0}] is OCSP valid for all eligible links, thus peer is valid";
|
||||
public const string DbgNoOCSPValidChains = "No OCSP valid chains, thus peer is invalid";
|
||||
public const string DbgCheckingCacheForCert = "Checking OCSP peer cache for [{0}], key [{1}]";
|
||||
public const string DbgCurrentResponseCached = "Cached OCSP response is current, status [{0}]";
|
||||
public const string DbgExpiredResponseCached = "Cached OCSP response is expired, status [{0}]";
|
||||
public const string DbgOCSPValidPeerLink = "OCSP verify pass for [{0}]";
|
||||
public const string DbgMakingCARequest = "Making OCSP CA request to [{0}]";
|
||||
public const string DbgResponseExpired = "OCSP response expired: NextUpdate={0}, now={1}, skew={2}";
|
||||
public const string DbgResponseTTLExpired = "OCSP response TTL expired: expiry={0}, now={1}, skew={2}";
|
||||
public const string DbgResponseFutureDated = "OCSP response is future-dated: ThisUpdate={0}, now={1}, skew={2}";
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
|
||||
|
||||
/// <summary>OCSP certificate status values.</summary>
|
||||
/// <remarks>Mirrors the Go <c>ocsp.Good/Revoked/Unknown</c> constants (0/1/2).</remarks>
|
||||
[JsonConverter(typeof(OcspStatusAssertionJsonConverter))]
|
||||
public enum OcspStatusAssertion
|
||||
{
|
||||
Good = 0,
|
||||
Revoked = 1,
|
||||
Unknown = 2,
|
||||
}
|
||||
|
||||
/// <summary>JSON converter: serializes <see cref="OcspStatusAssertion"/> as lowercase string.</summary>
|
||||
public sealed class OcspStatusAssertionJsonConverter : JsonConverter<OcspStatusAssertion>
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, OcspStatusAssertion> StrToVal =
|
||||
new Dictionary<string, OcspStatusAssertion>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["good"] = OcspStatusAssertion.Good,
|
||||
["revoked"] = OcspStatusAssertion.Revoked,
|
||||
["unknown"] = OcspStatusAssertion.Unknown,
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<OcspStatusAssertion, string> ValToStr =
|
||||
new Dictionary<OcspStatusAssertion, string>
|
||||
{
|
||||
[OcspStatusAssertion.Good] = "good",
|
||||
[OcspStatusAssertion.Revoked] = "revoked",
|
||||
[OcspStatusAssertion.Unknown] = "unknown",
|
||||
};
|
||||
|
||||
public override OcspStatusAssertion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var s = reader.GetString() ?? string.Empty;
|
||||
return StrToVal.TryGetValue(s, out var v) ? v : OcspStatusAssertion.Unknown;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, OcspStatusAssertion value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(ValToStr.TryGetValue(value, out var s) ? s : "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the string representation of an OCSP status integer.
|
||||
/// Falls back to "unknown" for unrecognized values (never defaults to "good").
|
||||
/// </summary>
|
||||
public static class OcspStatusAssertionExtensions
|
||||
{
|
||||
public static string GetStatusAssertionStr(int statusInt) => statusInt switch
|
||||
{
|
||||
0 => "good",
|
||||
1 => "revoked",
|
||||
_ => "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Parsed OCSP peer configuration.</summary>
|
||||
public sealed class OcspPeerConfig
|
||||
{
|
||||
public static readonly TimeSpan DefaultAllowedClockSkew = TimeSpan.FromSeconds(30);
|
||||
public static readonly TimeSpan DefaultOCSPResponderTimeout = TimeSpan.FromSeconds(2);
|
||||
public static readonly TimeSpan DefaultTTLUnsetNextUpdate = TimeSpan.FromHours(1);
|
||||
|
||||
public bool Verify { get; set; } = false;
|
||||
public double Timeout { get; set; } = DefaultOCSPResponderTimeout.TotalSeconds;
|
||||
public double ClockSkew { get; set; } = DefaultAllowedClockSkew.TotalSeconds;
|
||||
public bool WarnOnly { get; set; } = false;
|
||||
public bool UnknownIsGood { get; set; } = false;
|
||||
public bool AllowWhenCAUnreachable { get; set; } = false;
|
||||
public double TTLUnsetNextUpdate { get; set; } = DefaultTTLUnsetNextUpdate.TotalSeconds;
|
||||
|
||||
/// <summary>Returns a new <see cref="OcspPeerConfig"/> with defaults populated.</summary>
|
||||
public static OcspPeerConfig Create() => new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a certificate chain link: a leaf certificate and its issuer,
|
||||
/// plus the OCSP web endpoints parsed from the leaf's AIA extension.
|
||||
/// </summary>
|
||||
public sealed class ChainLink
|
||||
{
|
||||
public X509Certificate2? Leaf { get; set; }
|
||||
public X509Certificate2? Issuer { get; set; }
|
||||
public IReadOnlyList<Uri>? OcspWebEndpoints { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed OCSP response data. Mirrors the fields of <c>golang.org/x/crypto/ocsp.Response</c>
|
||||
/// needed by <see cref="OcspUtilities"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Full OCSP response parsing (DER/ASN.1) requires an additional library (e.g. Bouncy Castle).
|
||||
/// This type represents the already-parsed response for use in validation and caching logic.
|
||||
/// </remarks>
|
||||
public sealed class OcspResponse
|
||||
{
|
||||
public OcspStatusAssertion Status { get; init; }
|
||||
public DateTime ThisUpdate { get; init; }
|
||||
/// <summary><see cref="DateTime.MinValue"/> means "not set" (CA did not supply NextUpdate).</summary>
|
||||
public DateTime NextUpdate { get; init; }
|
||||
/// <summary>Optional delegated signer certificate (RFC 6960 §4.2.2.2).</summary>
|
||||
public X509Certificate2? Certificate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Neutral logging interface for plugin use. Mirrors the Go <c>certidp.Log</c> struct.</summary>
|
||||
public sealed class OcspLog
|
||||
{
|
||||
public Action<string, object[]>? Debugf { get; set; }
|
||||
public Action<string, object[]>? Noticef { get; set; }
|
||||
public Action<string, object[]>? Warnf { get; set; }
|
||||
public Action<string, object[]>? Errorf { get; set; }
|
||||
public Action<string, object[]>? Tracef { get; set; }
|
||||
|
||||
internal void Debug(string format, params object[] args) => Debugf?.Invoke(format, args);
|
||||
}
|
||||
|
||||
/// <summary>JSON-serializable certificate information.</summary>
|
||||
public sealed class CertInfo
|
||||
{
|
||||
[JsonPropertyName("subject")] public string? Subject { get; init; }
|
||||
[JsonPropertyName("issuer")] public string? Issuer { get; init; }
|
||||
[JsonPropertyName("fingerprint")] public string? Fingerprint { get; init; }
|
||||
[JsonPropertyName("raw")] public byte[]? Raw { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Net.Http;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
|
||||
|
||||
/// <summary>
|
||||
/// OCSP responder communication: fetches raw OCSP response bytes from CA endpoints.
|
||||
/// Mirrors certidp/ocsp_responder.go.
|
||||
/// </summary>
|
||||
public static class OcspResponder
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches an OCSP response from the responder URLs in <paramref name="link"/>.
|
||||
/// Tries each endpoint in order and returns the first successful response.
|
||||
/// </summary>
|
||||
/// <param name="link">Chain link containing leaf cert, issuer cert, and OCSP endpoints.</param>
|
||||
/// <param name="opts">Configuration (timeout, etc.).</param>
|
||||
/// <param name="log">Optional logger.</param>
|
||||
/// <param name="ocspRequest">DER-encoded OCSP request bytes to send.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Raw DER bytes of the OCSP response.</returns>
|
||||
public static async Task<byte[]> FetchOCSPResponseAsync(
|
||||
ChainLink link,
|
||||
OcspPeerConfig opts,
|
||||
byte[] ocspRequest,
|
||||
OcspLog? log = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (link.Leaf is null || link.Issuer is null)
|
||||
throw new ArgumentException(OcspMessages.ErrInvalidChainlink, nameof(link));
|
||||
if (link.OcspWebEndpoints is null || link.OcspWebEndpoints.Count == 0)
|
||||
throw new InvalidOperationException(OcspMessages.ErrNoAvailOCSPServers);
|
||||
|
||||
var timeout = TimeSpan.FromSeconds(opts.Timeout <= 0
|
||||
? OcspPeerConfig.DefaultOCSPResponderTimeout.TotalSeconds
|
||||
: opts.Timeout);
|
||||
|
||||
var reqEnc = EncodeOCSPRequest(ocspRequest);
|
||||
|
||||
using var hc = new HttpClient { Timeout = timeout };
|
||||
|
||||
Exception? lastError = null;
|
||||
foreach (var endpoint in link.OcspWebEndpoints)
|
||||
{
|
||||
var responderUrl = endpoint.ToString().TrimEnd('/');
|
||||
log?.Debug(OcspMessages.DbgMakingCARequest, responderUrl);
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{responderUrl}/{reqEnc}";
|
||||
using var response = await hc.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException(
|
||||
string.Format(OcspMessages.ErrBadResponderHTTPStatus, (int)response.StatusCode));
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
string.Format(OcspMessages.ErrFailedWithAllRequests, lastError?.Message), lastError);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encodes the OCSP request DER bytes and URL-escapes the result
|
||||
/// for use as a path segment (RFC 6960 Appendix A.1).
|
||||
/// </summary>
|
||||
public static string EncodeOCSPRequest(byte[] reqDer) =>
|
||||
Uri.EscapeDataString(Convert.ToBase64String(reqDer));
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Utility methods for OCSP peer certificate validation.
|
||||
/// Mirrors certidp/certidp.go.
|
||||
/// </summary>
|
||||
public static class OcspUtilities
|
||||
{
|
||||
// OCSP AIA extension OID.
|
||||
private const string OidAuthorityInfoAccess = "1.3.6.1.5.5.7.1.1";
|
||||
// OCSPSigning extended key usage OID.
|
||||
private const string OidOcspSigning = "1.3.6.1.5.5.7.3.9";
|
||||
|
||||
/// <summary>Returns the SHA-256 fingerprint of the certificate's raw DER bytes, base64-encoded.</summary>
|
||||
public static string GenerateFingerprint(X509Certificate2 cert)
|
||||
{
|
||||
var hash = SHA256.HashData(cert.RawData);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters a list of URI strings to those that are valid HTTP or HTTPS URLs.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<Uri> GetWebEndpoints(IEnumerable<string> uris)
|
||||
{
|
||||
var result = new List<Uri>();
|
||||
foreach (var uri in uris)
|
||||
{
|
||||
if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed))
|
||||
continue;
|
||||
if (parsed.Scheme != "http" && parsed.Scheme != "https")
|
||||
continue;
|
||||
result.Add(parsed);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the certificate subject in RDN sequence form, for logging.
|
||||
/// Not suitable for reliable cache matching.
|
||||
/// </summary>
|
||||
public static string GetSubjectDNForm(X509Certificate2? cert) =>
|
||||
cert is null ? string.Empty : cert.Subject;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the certificate issuer in RDN sequence form, for logging.
|
||||
/// Not suitable for reliable cache matching.
|
||||
/// </summary>
|
||||
public static string GetIssuerDNForm(X509Certificate2? cert) =>
|
||||
cert is null ? string.Empty : cert.Issuer;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the leaf certificate in the chain has OCSP responder endpoints
|
||||
/// in its Authority Information Access extension.
|
||||
/// Also populates <see cref="ChainLink.OcspWebEndpoints"/> on the link.
|
||||
/// </summary>
|
||||
public static bool CertOCSPEligible(ChainLink? link)
|
||||
{
|
||||
if (link?.Leaf is null || link.Leaf.RawData is not { Length: > 0 })
|
||||
return false;
|
||||
|
||||
var ocspUris = GetOcspUris(link.Leaf);
|
||||
var endpoints = GetWebEndpoints(ocspUris);
|
||||
if (endpoints.Count == 0)
|
||||
return false;
|
||||
|
||||
link.OcspWebEndpoints = endpoints;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the issuer certificate at position <paramref name="leafPos"/> + 1 in the chain.
|
||||
/// Returns null if the chain is too short or the leaf is self-signed.
|
||||
/// </summary>
|
||||
public static X509Certificate2? GetLeafIssuerCert(IReadOnlyList<X509Certificate2> chain, int leafPos)
|
||||
{
|
||||
if (chain.Count == 0 || leafPos < 0)
|
||||
return null;
|
||||
if (leafPos >= chain.Count - 1)
|
||||
return null;
|
||||
return chain[leafPos + 1];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the OCSP response is still current within the configured clock skew.
|
||||
/// </summary>
|
||||
public static bool OCSPResponseCurrent(OcspResponse response, OcspPeerConfig opts, OcspLog? log = null)
|
||||
{
|
||||
var skew = TimeSpan.FromSeconds(opts.ClockSkew < 0 ? OcspPeerConfig.DefaultAllowedClockSkew.TotalSeconds : opts.ClockSkew);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Check NextUpdate (when set by CA).
|
||||
if (response.NextUpdate != DateTime.MinValue && response.NextUpdate < now - skew)
|
||||
{
|
||||
log?.Debug(OcspMessages.DbgResponseExpired,
|
||||
response.NextUpdate.ToString("o"), now.ToString("o"), skew);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If NextUpdate not set, apply TTL from ThisUpdate.
|
||||
if (response.NextUpdate == DateTime.MinValue)
|
||||
{
|
||||
var ttl = TimeSpan.FromSeconds(opts.TTLUnsetNextUpdate < 0
|
||||
? OcspPeerConfig.DefaultTTLUnsetNextUpdate.TotalSeconds
|
||||
: opts.TTLUnsetNextUpdate);
|
||||
var expiry = response.ThisUpdate + ttl;
|
||||
if (expiry < now - skew)
|
||||
{
|
||||
log?.Debug(OcspMessages.DbgResponseTTLExpired,
|
||||
expiry.ToString("o"), now.ToString("o"), skew);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check ThisUpdate is not future-dated.
|
||||
if (response.ThisUpdate > now + skew)
|
||||
{
|
||||
log?.Debug(OcspMessages.DbgResponseFutureDated,
|
||||
response.ThisUpdate.ToString("o"), now.ToString("o"), skew);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the OCSP response was signed by a valid CA issuer or authorised delegate
|
||||
/// per RFC 6960 §4.2.2.2.
|
||||
/// </summary>
|
||||
public static bool ValidDelegationCheck(X509Certificate2? issuer, OcspResponse? response)
|
||||
{
|
||||
if (issuer is null || response is null)
|
||||
return false;
|
||||
|
||||
// Not a delegated response — the CA signed directly.
|
||||
if (response.Certificate is null)
|
||||
return true;
|
||||
|
||||
// Delegate is the same as the issuer — effectively a direct signing.
|
||||
if (response.Certificate.Thumbprint == issuer.Thumbprint)
|
||||
return true;
|
||||
|
||||
// Check the delegate has id-kp-OCSPSigning in its extended key usage.
|
||||
foreach (var ext in response.Certificate.Extensions)
|
||||
{
|
||||
if (ext is not X509EnhancedKeyUsageExtension eku)
|
||||
continue;
|
||||
foreach (var oid in eku.EnhancedKeyUsages)
|
||||
{
|
||||
if (oid.Value == OidOcspSigning)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private static IEnumerable<string> GetOcspUris(X509Certificate2 cert)
|
||||
{
|
||||
foreach (var ext in cert.Extensions)
|
||||
{
|
||||
if (ext.Oid?.Value != OidAuthorityInfoAccess)
|
||||
continue;
|
||||
foreach (var uri in ParseAiaUris(ext.RawData, isOcsp: true))
|
||||
yield return uri;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ParseAiaUris(byte[] aiaExtDer, bool isOcsp)
|
||||
{
|
||||
// OID for id-ad-ocsp: 1.3.6.1.5.5.7.48.1 → 2B 06 01 05 05 07 30 01
|
||||
byte[] ocspOid = [0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x01];
|
||||
// OID for id-ad-caIssuers: 1.3.6.1.5.5.7.48.2 → 2B 06 01 05 05 07 30 02
|
||||
byte[] caIssuersOid = [0x2B, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x02];
|
||||
|
||||
var target = isOcsp ? ocspOid : caIssuersOid;
|
||||
var result = new List<string>();
|
||||
int i = 0;
|
||||
|
||||
while (i < aiaExtDer.Length - target.Length - 4)
|
||||
{
|
||||
// Look for OID tag (0x06) followed by length matching our OID.
|
||||
if (aiaExtDer[i] == 0x06 && i + 1 < aiaExtDer.Length && aiaExtDer[i + 1] == target.Length)
|
||||
{
|
||||
var match = true;
|
||||
for (int k = 0; k < target.Length; k++)
|
||||
{
|
||||
if (aiaExtDer[i + 2 + k] != target[k]) { match = false; break; }
|
||||
}
|
||||
if (match)
|
||||
{
|
||||
// Next element should be context [6] IA5String (GeneralName uniformResourceIdentifier).
|
||||
int pos = i + 2 + target.Length;
|
||||
if (pos < aiaExtDer.Length && aiaExtDer[pos] == 0x86)
|
||||
{
|
||||
pos++;
|
||||
if (pos < aiaExtDer.Length)
|
||||
{
|
||||
int len = aiaExtDer[pos++];
|
||||
if (pos + len <= aiaExtDer.Length)
|
||||
{
|
||||
result.Add(System.Text.Encoding.ASCII.GetString(aiaExtDer, pos, len));
|
||||
i = pos + len;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright 2022-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth.CertificateStore;
|
||||
|
||||
/// <summary>
|
||||
/// Windows certificate store location.
|
||||
/// Mirrors the Go certstore <c>StoreType</c> enum (windowsCurrentUser=1, windowsLocalMachine=2).
|
||||
/// </summary>
|
||||
public enum StoreType
|
||||
{
|
||||
Empty = 0,
|
||||
WindowsCurrentUser = 1,
|
||||
WindowsLocalMachine = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Certificate lookup criterion.
|
||||
/// Mirrors the Go certstore <c>MatchByType</c> enum (matchByIssuer=1, matchBySubject=2, matchByThumbprint=3).
|
||||
/// </summary>
|
||||
public enum MatchByType
|
||||
{
|
||||
Empty = 0,
|
||||
Issuer = 1,
|
||||
Subject = 2,
|
||||
Thumbprint = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result returned by <see cref="CertificateStoreService.TLSConfig"/>.
|
||||
/// Mirrors the data that the Go <c>TLSConfig</c> populates into <c>*tls.Config</c>.
|
||||
/// </summary>
|
||||
public sealed class CertStoreTlsResult
|
||||
{
|
||||
public CertStoreTlsResult(X509Certificate2 leaf, X509Certificate2Collection? caCerts = null)
|
||||
{
|
||||
Leaf = leaf;
|
||||
CaCerts = caCerts;
|
||||
}
|
||||
|
||||
/// <summary>The leaf certificate (with private key) to use as the server/client identity.</summary>
|
||||
public X509Certificate2 Leaf { get; }
|
||||
|
||||
/// <summary>Optional pool of CA certificates used to validate client certificates (mTLS).</summary>
|
||||
public X509Certificate2Collection? CaCerts { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error constants for the Windows certificate store module.
|
||||
/// Mirrors certstore/errors.go.
|
||||
/// </summary>
|
||||
public static class CertStoreErrors
|
||||
{
|
||||
public static readonly InvalidOperationException ErrBadCryptoStoreProvider =
|
||||
new("unable to open certificate store or store not available");
|
||||
|
||||
public static readonly InvalidOperationException ErrBadRSAHashAlgorithm =
|
||||
new("unsupported RSA hash algorithm");
|
||||
|
||||
public static readonly InvalidOperationException ErrBadSigningAlgorithm =
|
||||
new("unsupported signing algorithm");
|
||||
|
||||
public static readonly InvalidOperationException ErrStoreRSASigningError =
|
||||
new("unable to obtain RSA signature from store");
|
||||
|
||||
public static readonly InvalidOperationException ErrStoreECDSASigningError =
|
||||
new("unable to obtain ECDSA signature from store");
|
||||
|
||||
public static readonly InvalidOperationException ErrNoPrivateKeyStoreRef =
|
||||
new("unable to obtain private key handle from store");
|
||||
|
||||
public static readonly InvalidOperationException ErrExtractingPrivateKeyMetadata =
|
||||
new("unable to extract private key metadata");
|
||||
|
||||
public static readonly InvalidOperationException ErrExtractingECCPublicKey =
|
||||
new("unable to extract ECC public key from store");
|
||||
|
||||
public static readonly InvalidOperationException ErrExtractingRSAPublicKey =
|
||||
new("unable to extract RSA public key from store");
|
||||
|
||||
public static readonly InvalidOperationException ErrExtractingPublicKey =
|
||||
new("unable to extract public key from store");
|
||||
|
||||
public static readonly InvalidOperationException ErrBadPublicKeyAlgorithm =
|
||||
new("unsupported public key algorithm");
|
||||
|
||||
public static readonly InvalidOperationException ErrExtractPropertyFromKey =
|
||||
new("unable to extract property from key");
|
||||
|
||||
public static readonly InvalidOperationException ErrBadECCCurveName =
|
||||
new("unsupported ECC curve name");
|
||||
|
||||
public static readonly InvalidOperationException ErrFailedCertSearch =
|
||||
new("unable to find certificate in store");
|
||||
|
||||
public static readonly InvalidOperationException ErrFailedX509Extract =
|
||||
new("unable to extract x509 from certificate");
|
||||
|
||||
public static readonly InvalidOperationException ErrBadMatchByType =
|
||||
new("cert match by type not implemented");
|
||||
|
||||
public static readonly InvalidOperationException ErrBadCertStore =
|
||||
new("cert store type not implemented");
|
||||
|
||||
public static readonly InvalidOperationException ErrConflictCertFileAndStore =
|
||||
new("'cert_file' and 'cert_store' may not both be configured");
|
||||
|
||||
public static readonly InvalidOperationException ErrBadCertStoreField =
|
||||
new("expected 'cert_store' to be a valid non-empty string");
|
||||
|
||||
public static readonly InvalidOperationException ErrBadCertMatchByField =
|
||||
new("expected 'cert_match_by' to be a valid non-empty string");
|
||||
|
||||
public static readonly InvalidOperationException ErrBadCertMatchField =
|
||||
new("expected 'cert_match' to be a valid non-empty string");
|
||||
|
||||
public static readonly InvalidOperationException ErrBadCaCertMatchField =
|
||||
new("expected 'ca_certs_match' to be a valid non-empty string array");
|
||||
|
||||
public static readonly InvalidOperationException ErrBadCertMatchSkipInvalidField =
|
||||
new("expected 'cert_match_skip_invalid' to be a boolean");
|
||||
|
||||
public static readonly InvalidOperationException ErrOSNotCompatCertStore =
|
||||
new("cert_store not compatible with current operating system");
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// Copyright 2022-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from certstore/certstore.go and certstore/certstore_windows.go in
|
||||
// the NATS server Go source. The .NET implementation uses System.Security.
|
||||
// Cryptography.X509Certificates.X509Store in place of Win32 P/Invoke calls.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth.CertificateStore;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the Windows certificate store for TLS certificate provisioning.
|
||||
/// Mirrors certstore/certstore.go and certstore/certstore_windows.go.
|
||||
///
|
||||
/// On non-Windows platforms all methods that require the Windows store throw
|
||||
/// <see cref="CertStoreErrors.ErrOSNotCompatCertStore"/>.
|
||||
/// </summary>
|
||||
public static class CertificateStoreService
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, StoreType> StoreMap =
|
||||
new Dictionary<string, StoreType>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["windowscurrentuser"] = StoreType.WindowsCurrentUser,
|
||||
["windowslocalmachine"] = StoreType.WindowsLocalMachine,
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, MatchByType> MatchByMap =
|
||||
new Dictionary<string, MatchByType>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["issuer"] = MatchByType.Issuer,
|
||||
["subject"] = MatchByType.Subject,
|
||||
["thumbprint"] = MatchByType.Thumbprint,
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cross-platform parse helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Parses a cert_store string to a <see cref="StoreType"/>.
|
||||
/// Returns an error if the string is unrecognised or not valid on the current OS.
|
||||
/// Mirrors <c>ParseCertStore</c>.
|
||||
/// </summary>
|
||||
public static (StoreType store, Exception? error) ParseCertStore(string certStore)
|
||||
{
|
||||
if (!StoreMap.TryGetValue(certStore, out var st))
|
||||
return (StoreType.Empty, CertStoreErrors.ErrBadCertStore);
|
||||
|
||||
// All currently supported store types are Windows-only.
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return (StoreType.Empty, CertStoreErrors.ErrOSNotCompatCertStore);
|
||||
|
||||
return (st, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a cert_match_by string to a <see cref="MatchByType"/>.
|
||||
/// Mirrors <c>ParseCertMatchBy</c>.
|
||||
/// </summary>
|
||||
public static (MatchByType matchBy, Exception? error) ParseCertMatchBy(string certMatchBy)
|
||||
{
|
||||
if (!MatchByMap.TryGetValue(certMatchBy, out var mb))
|
||||
return (MatchByType.Empty, CertStoreErrors.ErrBadMatchByType);
|
||||
return (mb, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the issuer certificate for <paramref name="leaf"/> by building a chain.
|
||||
/// Returns null if the chain cannot be built or the leaf is self-signed.
|
||||
/// Mirrors <c>GetLeafIssuer</c>.
|
||||
/// </summary>
|
||||
public static X509Certificate2? GetLeafIssuer(X509Certificate2 leaf)
|
||||
{
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||
|
||||
if (!chain.Build(leaf) || chain.ChainElements.Count < 2)
|
||||
return null;
|
||||
|
||||
// chain.ChainElements[0] is the leaf; [1] is its issuer.
|
||||
return new X509Certificate2(chain.ChainElements[1].Certificate);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TLS configuration entry point
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Finds a certificate in the Windows certificate store matching the given criteria and
|
||||
/// returns a <see cref="CertStoreTlsResult"/> suitable for populating TLS options.
|
||||
///
|
||||
/// On non-Windows platforms throws <see cref="CertStoreErrors.ErrOSNotCompatCertStore"/>.
|
||||
/// Mirrors <c>TLSConfig</c> (certstore_windows.go).
|
||||
/// </summary>
|
||||
/// <param name="storeType">Which Windows store to use (CurrentUser or LocalMachine).</param>
|
||||
/// <param name="matchBy">How to match the certificate (Subject, Issuer, or Thumbprint).</param>
|
||||
/// <param name="certMatch">The match value (subject name, issuer name, or thumbprint hex).</param>
|
||||
/// <param name="caCertsMatch">Optional list of subject strings to locate CA certificates.</param>
|
||||
/// <param name="skipInvalid">If true, skip expired or not-yet-valid certificates.</param>
|
||||
public static CertStoreTlsResult TLSConfig(
|
||||
StoreType storeType,
|
||||
MatchByType matchBy,
|
||||
string certMatch,
|
||||
IReadOnlyList<string>? caCertsMatch = null,
|
||||
bool skipInvalid = false)
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
throw CertStoreErrors.ErrOSNotCompatCertStore;
|
||||
if (storeType is not (StoreType.WindowsCurrentUser or StoreType.WindowsLocalMachine))
|
||||
throw CertStoreErrors.ErrBadCertStore;
|
||||
|
||||
var location = storeType == StoreType.WindowsCurrentUser
|
||||
? StoreLocation.CurrentUser
|
||||
: StoreLocation.LocalMachine;
|
||||
|
||||
// Find the leaf certificate.
|
||||
var leaf = matchBy switch
|
||||
{
|
||||
MatchByType.Subject or MatchByType.Empty => CertBySubject(certMatch, location, skipInvalid),
|
||||
MatchByType.Issuer => CertByIssuer(certMatch, location, skipInvalid),
|
||||
MatchByType.Thumbprint => CertByThumbprint(certMatch, location, skipInvalid),
|
||||
_ => throw CertStoreErrors.ErrBadMatchByType,
|
||||
} ?? throw CertStoreErrors.ErrFailedCertSearch;
|
||||
|
||||
// Optionally find CA certificates.
|
||||
X509Certificate2Collection? caPool = null;
|
||||
if (caCertsMatch is { Count: > 0 })
|
||||
caPool = CreateCACertsPool(location, caCertsMatch, skipInvalid);
|
||||
|
||||
return new CertStoreTlsResult(leaf, caPool);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Certificate search helpers (mirror winCertStore.certByXxx / certSearch)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Finds the first certificate in the personal (MY) store by subject name.
|
||||
/// Mirrors <c>certBySubject</c>.
|
||||
/// </summary>
|
||||
public static X509Certificate2? CertBySubject(string subject, StoreLocation location, bool skipInvalid) =>
|
||||
CertSearch(StoreName.My, location, X509FindType.FindBySubjectName, subject, skipInvalid);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the first certificate in the personal (MY) store by issuer name.
|
||||
/// Mirrors <c>certByIssuer</c>.
|
||||
/// </summary>
|
||||
public static X509Certificate2? CertByIssuer(string issuer, StoreLocation location, bool skipInvalid) =>
|
||||
CertSearch(StoreName.My, location, X509FindType.FindByIssuerName, issuer, skipInvalid);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the first certificate in the personal (MY) store by SHA-1 thumbprint (hex string).
|
||||
/// Mirrors <c>certByThumbprint</c>.
|
||||
/// </summary>
|
||||
public static X509Certificate2? CertByThumbprint(string thumbprint, StoreLocation location, bool skipInvalid) =>
|
||||
CertSearch(StoreName.My, location, X509FindType.FindByThumbprint, thumbprint, skipInvalid);
|
||||
|
||||
/// <summary>
|
||||
/// Searches Root, AuthRoot, and CA stores for certificates matching the given subject name.
|
||||
/// Returns all matching certificates across all three locations.
|
||||
/// Mirrors <c>caCertsBySubjectMatch</c>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<X509Certificate2> CaCertsBySubjectMatch(
|
||||
string subject,
|
||||
StoreLocation location,
|
||||
bool skipInvalid)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
throw CertStoreErrors.ErrBadCaCertMatchField;
|
||||
|
||||
var results = new List<X509Certificate2>();
|
||||
var searchLocations = new[] { StoreName.Root, StoreName.AuthRoot, StoreName.CertificateAuthority };
|
||||
|
||||
foreach (var storeName in searchLocations)
|
||||
{
|
||||
var cert = CertSearch(storeName, location, X509FindType.FindBySubjectName, subject, skipInvalid);
|
||||
if (cert != null)
|
||||
results.Add(cert);
|
||||
}
|
||||
|
||||
if (results.Count == 0)
|
||||
throw CertStoreErrors.ErrFailedCertSearch;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core certificate search — opens the specified store and finds a matching certificate.
|
||||
/// Returns null if not found.
|
||||
/// Mirrors <c>certSearch</c>.
|
||||
/// </summary>
|
||||
public static X509Certificate2? CertSearch(
|
||||
StoreName storeName,
|
||||
StoreLocation storeLocation,
|
||||
X509FindType findType,
|
||||
string findValue,
|
||||
bool skipInvalid)
|
||||
{
|
||||
using var store = new X509Store(storeName, storeLocation, OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
|
||||
var certs = store.Certificates.Find(findType, findValue, validOnly: skipInvalid);
|
||||
if (certs.Count == 0)
|
||||
return null;
|
||||
|
||||
// Pick first that has a private key (mirrors certKey requirement in Go).
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
if (cert.HasPrivateKey)
|
||||
return cert;
|
||||
}
|
||||
|
||||
// Fall back to first even without private key (e.g. CA cert lookup).
|
||||
return certs[0];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CA cert pool builder (mirrors createCACertsPool)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Builds a collection of CA certificates from the trusted Root, AuthRoot, and CA stores
|
||||
/// for each subject name in <paramref name="caCertsMatch"/>.
|
||||
/// Mirrors <c>createCACertsPool</c>.
|
||||
/// </summary>
|
||||
public static X509Certificate2Collection CreateCACertsPool(
|
||||
StoreLocation location,
|
||||
IReadOnlyList<string> caCertsMatch,
|
||||
bool skipInvalid)
|
||||
{
|
||||
var pool = new X509Certificate2Collection();
|
||||
var failCount = 0;
|
||||
|
||||
foreach (var subject in caCertsMatch)
|
||||
{
|
||||
try
|
||||
{
|
||||
var matches = CaCertsBySubjectMatch(subject, location, skipInvalid);
|
||||
foreach (var cert in matches)
|
||||
pool.Add(cert);
|
||||
}
|
||||
catch
|
||||
{
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount == caCertsMatch.Count)
|
||||
throw new InvalidOperationException("unable to match any CA certificate");
|
||||
|
||||
return pool;
|
||||
}
|
||||
}
|
||||
61
dotnet/src/ZB.MOM.NatsNet.Server/Auth/TpmKeyProvider.cs
Normal file
61
dotnet/src/ZB.MOM.NatsNet.Server/Auth/TpmKeyProvider.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Provides JetStream encryption key management via the Trusted Platform Module (TPM).
|
||||
/// Windows only — non-Windows platforms throw <see cref="PlatformNotSupportedException"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On Windows, the full implementation requires the Tpm2Lib NuGet package and accesses
|
||||
/// the TPM to seal/unseal keys using PCR-based authorization. The sealed public and
|
||||
/// private key blobs are persisted to disk as JSON.
|
||||
/// </remarks>
|
||||
public static class TpmKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads (or creates) the JetStream encryption key from the TPM.
|
||||
/// On first call (key file does not exist), generates a new NKey seed, seals it to the
|
||||
/// TPM, and writes the blobs to <paramref name="jsKeyFile"/>.
|
||||
/// On subsequent calls, reads the blobs from disk and unseals them using the TPM.
|
||||
/// </summary>
|
||||
/// <param name="srkPassword">Storage Root Key password (may be empty).</param>
|
||||
/// <param name="jsKeyFile">Path to the persisted key blobs JSON file.</param>
|
||||
/// <param name="jsKeyPassword">Password used to seal/unseal the JetStream key.</param>
|
||||
/// <param name="pcr">PCR index to bind the authorization policy to.</param>
|
||||
/// <returns>The JetStream encryption key seed string.</returns>
|
||||
/// <exception cref="PlatformNotSupportedException">Thrown on non-Windows platforms.</exception>
|
||||
public static string LoadJetStreamEncryptionKeyFromTpm(
|
||||
string srkPassword,
|
||||
string jsKeyFile,
|
||||
string jsKeyPassword,
|
||||
int pcr)
|
||||
{
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
throw new PlatformNotSupportedException("TPM functionality is not supported on this platform.");
|
||||
|
||||
// Windows implementation requires Tpm2Lib NuGet package.
|
||||
// Add <PackageReference Include="Tpm2Lib" Version="*" /> to the .csproj
|
||||
// under a Windows-conditional ItemGroup before enabling this path.
|
||||
throw new PlatformNotSupportedException(
|
||||
"TPM functionality is not supported on this platform. " +
|
||||
"On Windows, add Tpm2Lib NuGet package and implement via tpm2.OpenTPM().");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persisted TPM key blobs stored on disk as JSON.
|
||||
/// </summary>
|
||||
internal sealed class NatsPersistedTpmKeys
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
|
||||
[JsonPropertyName("private_key")]
|
||||
public byte[] PrivateKey { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("public_key")]
|
||||
public byte[] PublicKey { get; set; } = [];
|
||||
}
|
||||
100
dotnet/src/ZB.MOM.NatsNet.Server/Internal/AccessTimeService.cs
Normal file
100
dotnet/src/ZB.MOM.NatsNet.Server/Internal/AccessTimeService.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
namespace ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an efficiently-cached Unix nanosecond timestamp updated every
|
||||
/// <see cref="TickInterval"/> by a shared background timer.
|
||||
/// Register before use and Unregister when done; the timer shuts down when all
|
||||
/// registrants have unregistered.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors the Go <c>ats</c> package. Intended for high-frequency cache
|
||||
/// access-time reads that do not need sub-100ms precision.
|
||||
/// </remarks>
|
||||
public static class AccessTimeService
|
||||
{
|
||||
/// <summary>How often the cached time is refreshed.</summary>
|
||||
public static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
private static long _utime;
|
||||
private static long _refs;
|
||||
private static Timer? _timer;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
static AccessTimeService()
|
||||
{
|
||||
// Mirror Go's init(): nothing to pre-allocate in .NET.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a user. Starts the background timer when the first registrant calls this.
|
||||
/// Each call to <see cref="Register"/> must be paired with a call to <see cref="Unregister"/>.
|
||||
/// </summary>
|
||||
public static void Register()
|
||||
{
|
||||
var v = Interlocked.Increment(ref _refs);
|
||||
if (v == 1)
|
||||
{
|
||||
Interlocked.Exchange(ref _utime, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L);
|
||||
lock (_lock)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = new Timer(_ =>
|
||||
{
|
||||
Interlocked.Exchange(ref _utime, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L);
|
||||
}, null, TickInterval, TickInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters a user. Stops the background timer when the last registrant calls this.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown when unregister is called more times than register.</exception>
|
||||
public static void Unregister()
|
||||
{
|
||||
var v = Interlocked.Decrement(ref _refs);
|
||||
if (v == 0)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
else if (v < 0)
|
||||
{
|
||||
Interlocked.Exchange(ref _refs, 0);
|
||||
throw new InvalidOperationException("ats: unbalanced unregister for access time state");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the last cached Unix nanosecond timestamp.
|
||||
/// If no registrant is active, returns a fresh timestamp (avoids returning zero).
|
||||
/// </summary>
|
||||
public static long AccessTime()
|
||||
{
|
||||
var v = Interlocked.Read(ref _utime);
|
||||
if (v == 0)
|
||||
{
|
||||
v = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
|
||||
Interlocked.CompareExchange(ref _utime, v, 0);
|
||||
v = Interlocked.Read(ref _utime);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets all state. For testing only.
|
||||
/// </summary>
|
||||
internal static void Reset()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
Interlocked.Exchange(ref _refs, 0);
|
||||
Interlocked.Exchange(ref _utime, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
// Copyright 2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
|
||||
// Sublist is a routing mechanism to handle subject distribution and
|
||||
// provides a facility to match subjects from published messages to
|
||||
// interested subscribers. Subscribers can have wildcard subjects to
|
||||
// match multiple published subjects.
|
||||
|
||||
/// <summary>
|
||||
/// A value type used with <see cref="SimpleSublist"/> to track interest without
|
||||
/// storing any associated data. Equivalent to Go's <c>struct{}</c>.
|
||||
/// </summary>
|
||||
public readonly struct EmptyStruct : IEquatable<EmptyStruct>
|
||||
{
|
||||
public static readonly EmptyStruct Value = default;
|
||||
public bool Equals(EmptyStruct other) => true;
|
||||
public override bool Equals(object? obj) => obj is EmptyStruct;
|
||||
public override int GetHashCode() => 0;
|
||||
public static bool operator ==(EmptyStruct left, EmptyStruct right) => true;
|
||||
public static bool operator !=(EmptyStruct left, EmptyStruct right) => false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A thread-safe trie-based NATS subject routing list that efficiently stores and
|
||||
/// retrieves subscriptions. Wildcards <c>*</c> (single-token) and <c>></c>
|
||||
/// (full-wildcard) are supported.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The subscription value type. Must be non-null.</typeparam>
|
||||
public class GenericSublist<T> where T : notnull
|
||||
{
|
||||
// Token separator and wildcard constants (mirrors Go's const block).
|
||||
private const char Pwc = '*';
|
||||
private const char Fwc = '>';
|
||||
private const char Btsep = '.';
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public error singletons (mirrors Go's var block).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Thrown when a subject is syntactically invalid.</summary>
|
||||
public static readonly ArgumentException ErrInvalidSubject =
|
||||
new("gsl: invalid subject");
|
||||
|
||||
/// <summary>Thrown when a subscription is not found during removal.</summary>
|
||||
public static readonly KeyNotFoundException ErrNotFound =
|
||||
new("gsl: no matches found");
|
||||
|
||||
/// <summary>Thrown when a value is already registered for the given subject.</summary>
|
||||
public static readonly InvalidOperationException ErrAlreadyRegistered =
|
||||
new("gsl: notification already registered");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fields
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private readonly TrieLevel _root;
|
||||
private uint _count;
|
||||
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.NoRecursion);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Construction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
internal GenericSublist()
|
||||
{
|
||||
_root = new TrieLevel();
|
||||
}
|
||||
|
||||
/// <summary>Creates a new <see cref="GenericSublist{T}"/>.</summary>
|
||||
public static GenericSublist<T> NewSublist() => new();
|
||||
|
||||
/// <summary>Creates a new <see cref="SimpleSublist"/>.</summary>
|
||||
public static SimpleSublist NewSimpleSublist() => new();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Returns the total number of subscriptions stored.</summary>
|
||||
public uint Count
|
||||
{
|
||||
get
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try { return _count; }
|
||||
finally { _lock.ExitReadLock(); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a subscription into the trie.
|
||||
/// Throws <see cref="ArgumentException"/> if <paramref name="subject"/> is invalid.
|
||||
/// </summary>
|
||||
public void Insert(string subject, T value)
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
InsertCore(subject, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a subscription from the trie.
|
||||
/// Throws <see cref="ArgumentException"/> if the subject is invalid, or
|
||||
/// <see cref="KeyNotFoundException"/> if not found.
|
||||
/// </summary>
|
||||
public void Remove(string subject, T value)
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
RemoveCore(subject, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls <paramref name="action"/> for every value whose subscription matches
|
||||
/// the literal <paramref name="subject"/>.
|
||||
/// </summary>
|
||||
public void Match(string subject, Action<T> action)
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var tokens = TokenizeForMatch(subject);
|
||||
if (tokens == null) return;
|
||||
MatchLevel(_root, tokens, 0, action);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls <paramref name="action"/> for every value whose subscription matches
|
||||
/// <paramref name="subject"/> supplied as a UTF-8 byte span.
|
||||
/// </summary>
|
||||
public void MatchBytes(ReadOnlySpan<byte> subject, Action<T> action)
|
||||
{
|
||||
Match(System.Text.Encoding.UTF8.GetString(subject), action);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when at least one subscription matches
|
||||
/// <paramref name="subject"/>.
|
||||
/// </summary>
|
||||
public bool HasInterest(string subject)
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var tokens = TokenizeForMatch(subject);
|
||||
if (tokens == null) return false;
|
||||
int dummy = 0;
|
||||
return MatchLevelForAny(_root, tokens, 0, ref dummy);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of subscriptions that match <paramref name="subject"/>.
|
||||
/// </summary>
|
||||
public int NumInterest(string subject)
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var tokens = TokenizeForMatch(subject);
|
||||
if (tokens == null) return 0;
|
||||
int np = 0;
|
||||
MatchLevelForAny(_root, tokens, 0, ref np);
|
||||
return np;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if the trie contains any subscription that
|
||||
/// could match a subject whose tokens begin with the tokens of
|
||||
/// <paramref name="subject"/>. Used for trie intersection checks.
|
||||
/// </summary>
|
||||
public bool HasInterestStartingIn(string subject)
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var tokens = TokenizeSubjectIntoSlice(subject);
|
||||
return HasInterestStartingInLevel(_root, tokens, 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal helpers (accessible to tests in the same assembly).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Returns the maximum depth of the trie. Used in tests.</summary>
|
||||
internal int NumLevels() => VisitLevel(_root, 0);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private: Insert core (lock must be held by caller)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void InsertCore(string subject, T value)
|
||||
{
|
||||
var sfwc = false; // seen full-wildcard token
|
||||
TrieNode? n = null;
|
||||
var l = _root;
|
||||
|
||||
// Iterate tokens split by '.' using index arithmetic to avoid allocations.
|
||||
var start = 0;
|
||||
while (start <= subject.Length)
|
||||
{
|
||||
// Find end of this token.
|
||||
var end = subject.IndexOf(Btsep, start);
|
||||
var isLast = end < 0;
|
||||
if (isLast) end = subject.Length;
|
||||
|
||||
var tokenLen = end - start;
|
||||
|
||||
if (tokenLen == 0 || sfwc)
|
||||
throw new ArgumentException(ErrInvalidSubject.Message);
|
||||
|
||||
if (tokenLen > 1)
|
||||
{
|
||||
var t = subject.Substring(start, tokenLen);
|
||||
if (!l.Nodes.TryGetValue(t, out n))
|
||||
{
|
||||
n = new TrieNode();
|
||||
l.Nodes[t] = n;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (subject[start])
|
||||
{
|
||||
case Pwc:
|
||||
if (l.PwcNode == null) l.PwcNode = new TrieNode();
|
||||
n = l.PwcNode;
|
||||
break;
|
||||
case Fwc:
|
||||
if (l.FwcNode == null) l.FwcNode = new TrieNode();
|
||||
n = l.FwcNode;
|
||||
sfwc = true;
|
||||
break;
|
||||
default:
|
||||
var t = subject.Substring(start, 1);
|
||||
if (!l.Nodes.TryGetValue(t, out n))
|
||||
{
|
||||
n = new TrieNode();
|
||||
l.Nodes[t] = n;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
n.Next ??= new TrieLevel();
|
||||
l = n.Next;
|
||||
|
||||
if (isLast) break;
|
||||
start = end + 1;
|
||||
}
|
||||
|
||||
if (n == null)
|
||||
throw new ArgumentException(ErrInvalidSubject.Message);
|
||||
|
||||
n.Subs[value] = subject;
|
||||
_count++;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private: Remove core (lock must be held by caller)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void RemoveCore(string subject, T value)
|
||||
{
|
||||
var sfwc = false;
|
||||
var l = _root;
|
||||
|
||||
// We use a fixed-size stack-style array to track visited (level, node, token)
|
||||
// triples so we can prune upward after removal. 32 is the same as Go's [32]lnt.
|
||||
var levels = new LevelNodeToken[32];
|
||||
var levelCount = 0;
|
||||
TrieNode? n = null;
|
||||
|
||||
var start = 0;
|
||||
while (start <= subject.Length)
|
||||
{
|
||||
var end = subject.IndexOf(Btsep, start);
|
||||
var isLast = end < 0;
|
||||
if (isLast) end = subject.Length;
|
||||
|
||||
var tokenLen = end - start;
|
||||
|
||||
if (tokenLen == 0 || sfwc)
|
||||
throw new ArgumentException(ErrInvalidSubject.Message);
|
||||
|
||||
if (l == null!)
|
||||
throw new KeyNotFoundException(ErrNotFound.Message);
|
||||
|
||||
var tokenStr = subject.Substring(start, tokenLen);
|
||||
|
||||
if (tokenLen > 1)
|
||||
{
|
||||
l.Nodes.TryGetValue(tokenStr, out n);
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (tokenStr[0])
|
||||
{
|
||||
case Pwc:
|
||||
n = l.PwcNode;
|
||||
break;
|
||||
case Fwc:
|
||||
n = l.FwcNode;
|
||||
sfwc = true;
|
||||
break;
|
||||
default:
|
||||
l.Nodes.TryGetValue(tokenStr, out n);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (n != null)
|
||||
{
|
||||
if (levelCount < levels.Length)
|
||||
levels[levelCount++] = new LevelNodeToken(l, n, tokenStr);
|
||||
l = n.Next!;
|
||||
}
|
||||
else
|
||||
{
|
||||
l = null!;
|
||||
}
|
||||
|
||||
if (isLast) break;
|
||||
start = end + 1;
|
||||
}
|
||||
|
||||
// Remove from the final node's subscription map.
|
||||
if (!RemoveFromNode(n, value))
|
||||
throw new KeyNotFoundException(ErrNotFound.Message);
|
||||
|
||||
_count--;
|
||||
|
||||
// Prune empty nodes upward.
|
||||
for (var i = levelCount - 1; i >= 0; i--)
|
||||
{
|
||||
var (lv, nd, tk) = levels[i];
|
||||
if (nd.IsEmpty())
|
||||
lv.PruneNode(nd, tk);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RemoveFromNode(TrieNode? n, T value)
|
||||
{
|
||||
if (n == null) return false;
|
||||
return n.Subs.Remove(value);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private: matchLevel - recursive trie descent with callback
|
||||
// Mirrors Go's matchLevel function exactly.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static void MatchLevel(TrieLevel? l, string[] tokens, int start, Action<T> action)
|
||||
{
|
||||
TrieNode? pwc = null;
|
||||
TrieNode? n = null;
|
||||
|
||||
for (var i = start; i < tokens.Length; i++)
|
||||
{
|
||||
if (l == null) return;
|
||||
|
||||
// Full-wildcard at this level matches everything at/below.
|
||||
if (l.FwcNode != null)
|
||||
CallbacksForResults(l.FwcNode, action);
|
||||
|
||||
pwc = l.PwcNode;
|
||||
if (pwc != null)
|
||||
MatchLevel(pwc.Next, tokens, i + 1, action);
|
||||
|
||||
l.Nodes.TryGetValue(tokens[i], out n);
|
||||
l = n?.Next;
|
||||
}
|
||||
|
||||
// After consuming all tokens, emit subs from exact and pwc matches.
|
||||
if (n != null)
|
||||
CallbacksForResults(n, action);
|
||||
|
||||
if (pwc != null)
|
||||
CallbacksForResults(pwc, action);
|
||||
}
|
||||
|
||||
private static void CallbacksForResults(TrieNode n, Action<T> action)
|
||||
{
|
||||
foreach (var sub in n.Subs.Keys)
|
||||
action(sub);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private: matchLevelForAny - returns true on first match, counting via np
|
||||
// Mirrors Go's matchLevelForAny function exactly.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static bool MatchLevelForAny(TrieLevel? l, string[] tokens, int start, ref int np)
|
||||
{
|
||||
TrieNode? pwc = null;
|
||||
TrieNode? n = null;
|
||||
|
||||
for (var i = start; i < tokens.Length; i++)
|
||||
{
|
||||
if (l == null) return false;
|
||||
|
||||
if (l.FwcNode != null)
|
||||
{
|
||||
np += l.FwcNode.Subs.Count;
|
||||
return true;
|
||||
}
|
||||
|
||||
pwc = l.PwcNode;
|
||||
if (pwc != null)
|
||||
{
|
||||
if (MatchLevelForAny(pwc.Next, tokens, i + 1, ref np))
|
||||
return true;
|
||||
}
|
||||
|
||||
l.Nodes.TryGetValue(tokens[i], out n);
|
||||
l = n?.Next;
|
||||
}
|
||||
|
||||
if (n != null)
|
||||
{
|
||||
np += n.Subs.Count;
|
||||
if (n.Subs.Count > 0) return true;
|
||||
}
|
||||
|
||||
if (pwc != null)
|
||||
{
|
||||
np += pwc.Subs.Count;
|
||||
return pwc.Subs.Count > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private: hasInterestStartingIn - mirrors Go's hasInterestStartingIn
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static bool HasInterestStartingInLevel(TrieLevel? l, string[] tokens, int start)
|
||||
{
|
||||
if (l == null) return false;
|
||||
if (start >= tokens.Length) return true;
|
||||
|
||||
if (l.FwcNode != null) return true;
|
||||
|
||||
var found = false;
|
||||
if (l.PwcNode != null)
|
||||
found = HasInterestStartingInLevel(l.PwcNode.Next, tokens, start + 1);
|
||||
|
||||
if (!found && l.Nodes.TryGetValue(tokens[start], out var n))
|
||||
found = HasInterestStartingInLevel(n.Next, tokens, start + 1);
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private: numLevels helper - mirrors Go's visitLevel
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static int VisitLevel(TrieLevel? l, int depth)
|
||||
{
|
||||
if (l == null || l.NumNodes() == 0) return depth;
|
||||
|
||||
depth++;
|
||||
var maxDepth = depth;
|
||||
|
||||
foreach (var n in l.Nodes.Values)
|
||||
{
|
||||
var d = VisitLevel(n.Next, depth);
|
||||
if (d > maxDepth) maxDepth = d;
|
||||
}
|
||||
|
||||
if (l.PwcNode != null)
|
||||
{
|
||||
var d = VisitLevel(l.PwcNode.Next, depth);
|
||||
if (d > maxDepth) maxDepth = d;
|
||||
}
|
||||
|
||||
if (l.FwcNode != null)
|
||||
{
|
||||
var d = VisitLevel(l.FwcNode.Next, depth);
|
||||
if (d > maxDepth) maxDepth = d;
|
||||
}
|
||||
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private: tokenization helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Tokenizes a subject for match/hasInterest operations.
|
||||
/// Returns <see langword="null"/> if the subject contains an empty token,
|
||||
/// because an empty token can never match any subscription in the trie.
|
||||
/// Mirrors Go's inline tokenization in <c>match()</c> and <c>hasInterest()</c>.
|
||||
/// </summary>
|
||||
private static string[]? TokenizeForMatch(string subject)
|
||||
{
|
||||
if (subject.Length == 0) return null;
|
||||
|
||||
var tokens = new List<string>(8);
|
||||
var start = 0;
|
||||
|
||||
for (var i = 0; i < subject.Length; i++)
|
||||
{
|
||||
if (subject[i] == Btsep)
|
||||
{
|
||||
if (i - start == 0) return null; // empty token
|
||||
tokens.Add(subject.Substring(start, i - start));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing separator produces empty last token.
|
||||
if (start >= subject.Length) return null;
|
||||
tokens.Add(subject.Substring(start));
|
||||
return tokens.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tokenizes a subject into a string array without validation.
|
||||
/// Mirrors Go's <c>tokenizeSubjectIntoSlice</c>.
|
||||
/// </summary>
|
||||
private static string[] TokenizeSubjectIntoSlice(string subject)
|
||||
{
|
||||
var tokens = new List<string>(8);
|
||||
var start = 0;
|
||||
for (var i = 0; i < subject.Length; i++)
|
||||
{
|
||||
if (subject[i] == Btsep)
|
||||
{
|
||||
tokens.Add(subject.Substring(start, i - start));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
tokens.Add(subject.Substring(start));
|
||||
return tokens.ToArray();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private: Trie node and level types
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A trie node holding a subscription map and an optional link to the next level.
|
||||
/// Mirrors Go's <c>node[T]</c>.
|
||||
/// </summary>
|
||||
private sealed class TrieNode
|
||||
{
|
||||
/// <summary>Maps subscription value → original subject string.</summary>
|
||||
public readonly Dictionary<T, string> Subs = new();
|
||||
|
||||
/// <summary>The next trie level below this node, or null if at a leaf.</summary>
|
||||
public TrieLevel? Next;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the node has no subscriptions and no live children.
|
||||
/// Used during removal to decide whether to prune this node.
|
||||
/// Mirrors Go's <c>node.isEmpty()</c>.
|
||||
/// </summary>
|
||||
public bool IsEmpty() => Subs.Count == 0 && (Next == null || Next.NumNodes() == 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A trie level containing named child nodes and special wildcard slots.
|
||||
/// Mirrors Go's <c>level[T]</c>.
|
||||
/// </summary>
|
||||
private sealed class TrieLevel
|
||||
{
|
||||
public readonly Dictionary<string, TrieNode> Nodes = new();
|
||||
public TrieNode? PwcNode; // '*' single-token wildcard node
|
||||
public TrieNode? FwcNode; // '>' full-wildcard node
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total count of live nodes at this level.
|
||||
/// Mirrors Go's <c>level.numNodes()</c>.
|
||||
/// </summary>
|
||||
public int NumNodes()
|
||||
{
|
||||
var num = Nodes.Count;
|
||||
if (PwcNode != null) num++;
|
||||
if (FwcNode != null) num++;
|
||||
return num;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes an empty node from this level, using reference equality to
|
||||
/// distinguish wildcard slots from named slots.
|
||||
/// Mirrors Go's <c>level.pruneNode()</c>.
|
||||
/// </summary>
|
||||
public void PruneNode(TrieNode n, string token)
|
||||
{
|
||||
if (ReferenceEquals(n, FwcNode))
|
||||
FwcNode = null;
|
||||
else if (ReferenceEquals(n, PwcNode))
|
||||
PwcNode = null;
|
||||
else
|
||||
Nodes.Remove(token);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks a (level, node, token) triple during removal for upward pruning.
|
||||
/// Mirrors Go's <c>lnt[T]</c>.
|
||||
/// </summary>
|
||||
private readonly struct LevelNodeToken
|
||||
{
|
||||
public readonly TrieLevel Level;
|
||||
public readonly TrieNode Node;
|
||||
public readonly string Token;
|
||||
|
||||
public LevelNodeToken(TrieLevel level, TrieNode node, string token)
|
||||
{
|
||||
Level = level;
|
||||
Node = node;
|
||||
Token = token;
|
||||
}
|
||||
|
||||
public void Deconstruct(out TrieLevel level, out TrieNode node, out string token)
|
||||
{
|
||||
level = Level;
|
||||
node = Node;
|
||||
token = Token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A lightweight sublist that tracks interest only, without storing any associated data.
|
||||
/// Equivalent to Go's <c>SimpleSublist = GenericSublist[struct{}]</c>.
|
||||
/// </summary>
|
||||
public sealed class SimpleSublist : GenericSublist<EmptyStruct>
|
||||
{
|
||||
internal SimpleSublist() { }
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
|
||||
/// <summary>
|
||||
/// A time-based hash wheel for efficiently scheduling and expiring timer tasks keyed by sequence number.
|
||||
/// Each slot covers a 1-second window; the wheel has 4096 slots (covering ~68 minutes before wrapping).
|
||||
/// Not thread-safe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors the Go <c>thw.HashWheel</c> type. Timestamps are Unix nanoseconds (<see cref="long"/>).
|
||||
/// </remarks>
|
||||
public sealed class HashWheel
|
||||
{
|
||||
/// <summary>Slot width in nanoseconds (1 second).</summary>
|
||||
private const long TickDuration = 1_000_000_000L;
|
||||
private const int WheelBits = 12;
|
||||
private const int WheelSize = 1 << WheelBits; // 4096
|
||||
private const int WheelMask = WheelSize - 1;
|
||||
private const int HeaderLen = 17; // 1 magic + 8 count + 8 highSeq
|
||||
|
||||
public static readonly Exception ErrTaskNotFound = new InvalidOperationException("thw: task not found");
|
||||
public static readonly Exception ErrInvalidVersion = new InvalidDataException("thw: encoded version not known");
|
||||
|
||||
private readonly Slot?[] _wheel = new Slot?[WheelSize];
|
||||
private long _lowest = long.MaxValue;
|
||||
private ulong _count;
|
||||
|
||||
// --- Slot ---
|
||||
|
||||
private sealed class Slot
|
||||
{
|
||||
public readonly Dictionary<ulong, long> Entries = new();
|
||||
public long Lowest = long.MaxValue;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new empty <see cref="HashWheel"/>.</summary>
|
||||
public static HashWheel NewHashWheel() => new();
|
||||
|
||||
private static Slot NewSlot() => new();
|
||||
|
||||
private long GetPosition(long expires) => (expires / TickDuration) & WheelMask;
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
/// <summary>Returns the number of tasks currently scheduled.</summary>
|
||||
public ulong Count => _count;
|
||||
|
||||
/// <summary>Schedules a new timer task.</summary>
|
||||
public void Add(ulong seq, long expires)
|
||||
{
|
||||
var pos = (int)GetPosition(expires);
|
||||
_wheel[pos] ??= NewSlot();
|
||||
var slot = _wheel[pos]!;
|
||||
|
||||
if (!slot.Entries.ContainsKey(seq))
|
||||
_count++;
|
||||
slot.Entries[seq] = expires;
|
||||
|
||||
if (expires < slot.Lowest)
|
||||
{
|
||||
slot.Lowest = expires;
|
||||
if (expires < _lowest)
|
||||
_lowest = expires;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes a timer task.</summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown (as <see cref="ErrTaskNotFound"/>) when not found.</exception>
|
||||
public void Remove(ulong seq, long expires)
|
||||
{
|
||||
var pos = (int)GetPosition(expires);
|
||||
var slot = _wheel[pos];
|
||||
if (slot is null || !slot.Entries.ContainsKey(seq))
|
||||
throw ErrTaskNotFound;
|
||||
|
||||
slot.Entries.Remove(seq);
|
||||
_count--;
|
||||
if (slot.Entries.Count == 0)
|
||||
_wheel[pos] = null;
|
||||
}
|
||||
|
||||
/// <summary>Updates the expiration time of an existing timer task.</summary>
|
||||
public void Update(ulong seq, long oldExpires, long newExpires)
|
||||
{
|
||||
Remove(seq, oldExpires);
|
||||
Add(seq, newExpires);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expires all tasks whose timestamp is <= now. The callback receives each task;
|
||||
/// if it returns <see langword="true"/> the task is removed, otherwise it is kept.
|
||||
/// </summary>
|
||||
public void ExpireTasks(Func<ulong, long, bool> callback)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
|
||||
ExpireTasksInternal(now, callback);
|
||||
}
|
||||
|
||||
internal void ExpireTasksInternal(long ts, Func<ulong, long, bool> callback)
|
||||
{
|
||||
if (_lowest > ts)
|
||||
return;
|
||||
|
||||
var globalLowest = long.MaxValue;
|
||||
for (var pos = 0; pos < WheelSize; pos++)
|
||||
{
|
||||
var slot = _wheel[pos];
|
||||
if (slot is null || slot.Lowest > ts)
|
||||
{
|
||||
if (slot is not null && slot.Lowest < globalLowest)
|
||||
globalLowest = slot.Lowest;
|
||||
continue;
|
||||
}
|
||||
|
||||
var slotLowest = long.MaxValue;
|
||||
// Snapshot keys to allow removal during iteration.
|
||||
var keys = slot.Entries.Keys.ToArray();
|
||||
foreach (var seq in keys)
|
||||
{
|
||||
var exp = slot.Entries[seq];
|
||||
if (exp <= ts && callback(seq, exp))
|
||||
{
|
||||
slot.Entries.Remove(seq);
|
||||
_count--;
|
||||
continue;
|
||||
}
|
||||
if (exp < slotLowest)
|
||||
slotLowest = exp;
|
||||
}
|
||||
|
||||
if (slot.Entries.Count == 0)
|
||||
{
|
||||
_wheel[pos] = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
slot.Lowest = slotLowest;
|
||||
if (slotLowest < globalLowest)
|
||||
globalLowest = slotLowest;
|
||||
}
|
||||
}
|
||||
_lowest = globalLowest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the earliest expiration timestamp before <paramref name="before"/>,
|
||||
/// or <see cref="long.MaxValue"/> if none.
|
||||
/// </summary>
|
||||
public long GetNextExpiration(long before) =>
|
||||
_lowest < before ? _lowest : long.MaxValue;
|
||||
|
||||
// --- Encode / Decode ---
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the wheel to a byte array. <paramref name="highSeq"/> is stored
|
||||
/// in the header and returned by <see cref="Decode"/>.
|
||||
/// </summary>
|
||||
public byte[] Encode(ulong highSeq)
|
||||
{
|
||||
// Preallocate conservatively: header + up to 2 varints per entry.
|
||||
var buf = new List<byte>(HeaderLen + (int)(_count * 16));
|
||||
buf.Add(1); // magic version
|
||||
AppendUint64LE(buf, _count);
|
||||
AppendUint64LE(buf, highSeq);
|
||||
|
||||
foreach (var slot in _wheel)
|
||||
{
|
||||
if (slot is null)
|
||||
continue;
|
||||
foreach (var (seq, ts) in slot.Entries)
|
||||
{
|
||||
AppendVarint(buf, ts);
|
||||
AppendUvarint(buf, seq);
|
||||
}
|
||||
}
|
||||
return buf.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces this wheel's contents with those from a binary snapshot.
|
||||
/// Returns the <c>highSeq</c> stored in the header.
|
||||
/// </summary>
|
||||
public ulong Decode(ReadOnlySpan<byte> b)
|
||||
{
|
||||
if (b.Length < HeaderLen)
|
||||
throw (InvalidDataException)ErrInvalidVersion;
|
||||
if (b[0] != 1)
|
||||
throw (InvalidDataException)ErrInvalidVersion;
|
||||
|
||||
// Reset wheel.
|
||||
Array.Clear(_wheel);
|
||||
_lowest = long.MaxValue;
|
||||
_count = 0;
|
||||
|
||||
var count = BinaryPrimitives.ReadUInt64LittleEndian(b[1..]);
|
||||
var stamp = BinaryPrimitives.ReadUInt64LittleEndian(b[9..]);
|
||||
var pos = HeaderLen;
|
||||
|
||||
for (ulong i = 0; i < count; i++)
|
||||
{
|
||||
var ts = ReadVarint(b, ref pos);
|
||||
var seq = ReadUvarint(b, ref pos);
|
||||
Add(seq, ts);
|
||||
}
|
||||
return stamp;
|
||||
}
|
||||
|
||||
// --- Encoding helpers ---
|
||||
|
||||
private static void AppendUint64LE(List<byte> buf, ulong v)
|
||||
{
|
||||
buf.Add((byte)v);
|
||||
buf.Add((byte)(v >> 8));
|
||||
buf.Add((byte)(v >> 16));
|
||||
buf.Add((byte)(v >> 24));
|
||||
buf.Add((byte)(v >> 32));
|
||||
buf.Add((byte)(v >> 40));
|
||||
buf.Add((byte)(v >> 48));
|
||||
buf.Add((byte)(v >> 56));
|
||||
}
|
||||
|
||||
private static void AppendVarint(List<byte> buf, long v)
|
||||
{
|
||||
// ZigZag encode like Go's binary.AppendVarint.
|
||||
var uv = (ulong)((v << 1) ^ (v >> 63));
|
||||
AppendUvarint(buf, uv);
|
||||
}
|
||||
|
||||
private static void AppendUvarint(List<byte> buf, ulong v)
|
||||
{
|
||||
while (v >= 0x80)
|
||||
{
|
||||
buf.Add((byte)(v | 0x80));
|
||||
v >>= 7;
|
||||
}
|
||||
buf.Add((byte)v);
|
||||
}
|
||||
|
||||
private static long ReadVarint(ReadOnlySpan<byte> b, ref int pos)
|
||||
{
|
||||
var uv = ReadUvarint(b, ref pos);
|
||||
var v = (long)(uv >> 1);
|
||||
if ((uv & 1) != 0)
|
||||
v = ~v;
|
||||
return v;
|
||||
}
|
||||
|
||||
private static ulong ReadUvarint(ReadOnlySpan<byte> b, ref int pos)
|
||||
{
|
||||
ulong x = 0;
|
||||
int s = 0;
|
||||
while (pos < b.Length)
|
||||
{
|
||||
var by = b[pos++];
|
||||
x |= (ulong)(by & 0x7F) << s;
|
||||
if ((by & 0x80) == 0)
|
||||
return x;
|
||||
s += 7;
|
||||
}
|
||||
throw new InvalidDataException("thw: unexpected EOF in varint");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// Copyright 2023-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
|
||||
/// <summary>
|
||||
/// An adaptive radix trie (ART) for storing subject information on literal NATS subjects.
|
||||
/// Uses dynamic nodes (4/10/16/48/256 children), path compression, and lazy expansion.
|
||||
/// Supports exact lookup, wildcard matching ('*' and '>'), and ordered/fast iteration.
|
||||
/// Not thread-safe.
|
||||
/// </summary>
|
||||
public sealed class SubjectTree<T>
|
||||
{
|
||||
internal ISubjectTreeNode<T>? _root;
|
||||
private int _size;
|
||||
|
||||
/// <summary>Returns the number of entries stored in the tree.</summary>
|
||||
public int Size() => _size;
|
||||
|
||||
/// <summary>Returns true if the tree has no entries.</summary>
|
||||
public bool Empty() => _size == 0;
|
||||
|
||||
/// <summary>Clears all entries from the tree.</summary>
|
||||
public SubjectTree<T> Reset()
|
||||
{
|
||||
_root = null;
|
||||
_size = 0;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a value into the tree under the given subject.
|
||||
/// If the subject already exists, returns the old value with updated=true.
|
||||
/// Subjects containing byte 127 (the noPivot sentinel) are rejected silently.
|
||||
/// </summary>
|
||||
public (T? oldVal, bool updated) Insert(ReadOnlySpan<byte> subject, T value)
|
||||
{
|
||||
if (subject.IndexOf(SubjectTreeParts.NoPivot) >= 0)
|
||||
return (default, false);
|
||||
|
||||
var subjectBytes = subject.ToArray();
|
||||
var (old, updated) = DoInsert(ref _root, subjectBytes, value, 0);
|
||||
if (!updated)
|
||||
_size++;
|
||||
return (old, updated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the value stored at the given literal subject.
|
||||
/// Returns (value, true) if found, (default, false) otherwise.
|
||||
/// </summary>
|
||||
public (T? val, bool found) Find(ReadOnlySpan<byte> subject)
|
||||
{
|
||||
var si = 0;
|
||||
var n = _root;
|
||||
var subjectBytes = subject.ToArray();
|
||||
|
||||
while (n != null)
|
||||
{
|
||||
if (n.IsLeaf)
|
||||
{
|
||||
var ln = (SubjectTreeLeaf<T>)n;
|
||||
return ln.Match(subjectBytes.AsSpan(si))
|
||||
? (ln.Value, true)
|
||||
: (default, false);
|
||||
}
|
||||
|
||||
var prefix = n.Prefix;
|
||||
if (prefix.Length > 0)
|
||||
{
|
||||
var end = Math.Min(si + prefix.Length, subjectBytes.Length);
|
||||
if (!subjectBytes.AsSpan(si, end - si).SequenceEqual(prefix.AsSpan(0, end - si)))
|
||||
return (default, false);
|
||||
si += prefix.Length;
|
||||
}
|
||||
|
||||
var next = n.FindChild(SubjectTreeParts.Pivot(subjectBytes, si));
|
||||
if (next == null) return (default, false);
|
||||
n = next;
|
||||
}
|
||||
|
||||
return (default, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the entry at the given literal subject.
|
||||
/// Returns (value, true) if deleted, (default, false) if not found.
|
||||
/// </summary>
|
||||
public (T? val, bool found) Delete(ReadOnlySpan<byte> subject)
|
||||
{
|
||||
if (_root == null || subject.IsEmpty) return (default, false);
|
||||
|
||||
var subjectBytes = subject.ToArray();
|
||||
var (val, deleted) = DoDelete(ref _root, subjectBytes, 0);
|
||||
if (deleted) _size--;
|
||||
return (val, deleted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches all stored subjects against a filter that may contain wildcards ('*' and '>').
|
||||
/// Invokes fn for each match. Return false from the callback to stop early.
|
||||
/// </summary>
|
||||
public void Match(ReadOnlySpan<byte> filter, Func<byte[], T, bool> fn)
|
||||
{
|
||||
if (_root == null || filter.IsEmpty || fn == null) return;
|
||||
var parts = SubjectTreeParts.GenParts(filter.ToArray());
|
||||
MatchNode(_root, parts, Array.Empty<byte>(), fn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Like Match but returns false if the callback stopped iteration early.
|
||||
/// Returns true if matching ran to completion.
|
||||
/// </summary>
|
||||
public bool MatchUntil(ReadOnlySpan<byte> filter, Func<byte[], T, bool> fn)
|
||||
{
|
||||
if (_root == null || filter.IsEmpty || fn == null) return true;
|
||||
var parts = SubjectTreeParts.GenParts(filter.ToArray());
|
||||
return MatchNode(_root, parts, Array.Empty<byte>(), fn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks all entries in lexicographical order.
|
||||
/// Return false from the callback to stop early.
|
||||
/// </summary>
|
||||
public void IterOrdered(Func<byte[], T, bool> fn)
|
||||
{
|
||||
if (_root == null || fn == null) return;
|
||||
IterNode(_root, Array.Empty<byte>(), ordered: true, fn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks all entries in storage order (no ordering guarantee).
|
||||
/// Return false from the callback to stop early.
|
||||
/// </summary>
|
||||
public void IterFast(Func<byte[], T, bool> fn)
|
||||
{
|
||||
if (_root == null || fn == null) return;
|
||||
IterNode(_root, Array.Empty<byte>(), ordered: false, fn);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal recursive insert
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static (T? old, bool updated) DoInsert(ref ISubjectTreeNode<T>? np, byte[] subject, T value, int si)
|
||||
{
|
||||
if (np == null)
|
||||
{
|
||||
np = new SubjectTreeLeaf<T>(subject, value);
|
||||
return (default, false);
|
||||
}
|
||||
|
||||
if (np.IsLeaf)
|
||||
{
|
||||
var ln = (SubjectTreeLeaf<T>)np;
|
||||
if (ln.Match(subject.AsSpan(si)))
|
||||
{
|
||||
var oldVal = ln.Value;
|
||||
ln.Value = value;
|
||||
return (oldVal, true);
|
||||
}
|
||||
|
||||
// Split the leaf: compute common prefix between existing suffix and new subject tail.
|
||||
var cpi = SubjectTreeParts.CommonPrefixLen(ln.Suffix, subject.AsSpan(si));
|
||||
var nn = new SubjectTreeNode4<T>(subject[si..(si + cpi)]);
|
||||
ln.SetSuffix(ln.Suffix[cpi..]);
|
||||
si += cpi;
|
||||
|
||||
var p = SubjectTreeParts.Pivot(ln.Suffix, 0);
|
||||
if (cpi > 0 && si < subject.Length && p == subject[si])
|
||||
{
|
||||
// Same pivot after the split — recurse to separate further.
|
||||
DoInsert(ref np, subject, value, si);
|
||||
nn.AddChild(p, np!);
|
||||
}
|
||||
else
|
||||
{
|
||||
var nl = new SubjectTreeLeaf<T>(subject[si..], value);
|
||||
nn.AddChild(SubjectTreeParts.Pivot(nl.Suffix, 0), nl);
|
||||
nn.AddChild(SubjectTreeParts.Pivot(ln.Suffix, 0), ln);
|
||||
}
|
||||
|
||||
np = nn;
|
||||
return (default, false);
|
||||
}
|
||||
|
||||
// Non-leaf node.
|
||||
var prefix = np.Prefix;
|
||||
if (prefix.Length > 0)
|
||||
{
|
||||
var cpi = SubjectTreeParts.CommonPrefixLen(prefix, subject.AsSpan(si));
|
||||
if (cpi >= prefix.Length)
|
||||
{
|
||||
// Full prefix match: move past this node.
|
||||
si += prefix.Length;
|
||||
var pivotByte = SubjectTreeParts.Pivot(subject, si);
|
||||
var existingChild = np.FindChild(pivotByte);
|
||||
if (existingChild != null)
|
||||
{
|
||||
var before = existingChild;
|
||||
var (old, upd) = DoInsert(ref existingChild, subject, value, si);
|
||||
// Only re-register if the child reference changed identity (grew or split).
|
||||
if (!ReferenceEquals(before, existingChild))
|
||||
{
|
||||
np.DeleteChild(pivotByte);
|
||||
np.AddChild(pivotByte, existingChild!);
|
||||
}
|
||||
return (old, upd);
|
||||
}
|
||||
|
||||
if (np.IsFull)
|
||||
np = np.Grow();
|
||||
|
||||
np.AddChild(SubjectTreeParts.Pivot(subject, si), new SubjectTreeLeaf<T>(subject[si..], value));
|
||||
return (default, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Partial prefix match — insert a new node4 above the current node.
|
||||
var newPrefix = subject[si..(si + cpi)];
|
||||
si += cpi;
|
||||
var splitNode = new SubjectTreeNode4<T>(newPrefix);
|
||||
|
||||
((SubjectTreeMeta<T>)np).SetPrefix(prefix[cpi..]);
|
||||
// Use np.Prefix (updated) to get the correct pivot for the demoted node.
|
||||
splitNode.AddChild(SubjectTreeParts.Pivot(np.Prefix, 0), np);
|
||||
splitNode.AddChild(
|
||||
SubjectTreeParts.Pivot(subject.AsSpan(si), 0),
|
||||
new SubjectTreeLeaf<T>(subject[si..], value));
|
||||
np = splitNode;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No prefix on this node.
|
||||
var pivotByte = SubjectTreeParts.Pivot(subject, si);
|
||||
var existingChild = np.FindChild(pivotByte);
|
||||
if (existingChild != null)
|
||||
{
|
||||
var before = existingChild;
|
||||
var (old, upd) = DoInsert(ref existingChild, subject, value, si);
|
||||
if (!ReferenceEquals(before, existingChild))
|
||||
{
|
||||
np.DeleteChild(pivotByte);
|
||||
np.AddChild(pivotByte, existingChild!);
|
||||
}
|
||||
return (old, upd);
|
||||
}
|
||||
|
||||
if (np.IsFull)
|
||||
np = np.Grow();
|
||||
|
||||
np.AddChild(SubjectTreeParts.Pivot(subject, si), new SubjectTreeLeaf<T>(subject[si..], value));
|
||||
}
|
||||
|
||||
return (default, false);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal recursive delete
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static (T? val, bool deleted) DoDelete(ref ISubjectTreeNode<T>? np, byte[] subject, int si)
|
||||
{
|
||||
if (np == null || subject.Length == 0) return (default, false);
|
||||
|
||||
var n = np;
|
||||
if (n.IsLeaf)
|
||||
{
|
||||
var ln = (SubjectTreeLeaf<T>)n;
|
||||
if (ln.Match(subject.AsSpan(si)))
|
||||
{
|
||||
np = null;
|
||||
return (ln.Value, true);
|
||||
}
|
||||
return (default, false);
|
||||
}
|
||||
|
||||
// Check prefix.
|
||||
var prefix = n.Prefix;
|
||||
if (prefix.Length > 0)
|
||||
{
|
||||
if (subject.Length < si + prefix.Length)
|
||||
return (default, false);
|
||||
if (!subject.AsSpan(si, prefix.Length).SequenceEqual(prefix))
|
||||
return (default, false);
|
||||
si += prefix.Length;
|
||||
}
|
||||
|
||||
var p = SubjectTreeParts.Pivot(subject, si);
|
||||
var childNode = n.FindChild(p);
|
||||
if (childNode == null) return (default, false);
|
||||
|
||||
if (childNode.IsLeaf)
|
||||
{
|
||||
var childLeaf = (SubjectTreeLeaf<T>)childNode;
|
||||
if (childLeaf.Match(subject.AsSpan(si)))
|
||||
{
|
||||
n.DeleteChild(p);
|
||||
TryShrink(ref np!, prefix);
|
||||
return (childLeaf.Value, true);
|
||||
}
|
||||
return (default, false);
|
||||
}
|
||||
|
||||
// Recurse into non-leaf child.
|
||||
var (val, deleted) = DoDelete(ref childNode, subject, si);
|
||||
if (deleted)
|
||||
{
|
||||
if (childNode == null)
|
||||
{
|
||||
// Child was nulled out — remove slot and try to shrink.
|
||||
n.DeleteChild(p);
|
||||
TryShrink(ref np!, prefix);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Child changed identity — re-register.
|
||||
n.DeleteChild(p);
|
||||
n.AddChild(p, childNode);
|
||||
}
|
||||
}
|
||||
return (val, deleted);
|
||||
}
|
||||
|
||||
private static void TryShrink(ref ISubjectTreeNode<T> np, byte[] parentPrefix)
|
||||
{
|
||||
var shrunk = np.Shrink();
|
||||
if (shrunk == null) return;
|
||||
|
||||
if (shrunk.IsLeaf)
|
||||
{
|
||||
var shrunkLeaf = (SubjectTreeLeaf<T>)shrunk;
|
||||
if (parentPrefix.Length > 0)
|
||||
shrunkLeaf.Suffix = [.. parentPrefix, .. shrunkLeaf.Suffix];
|
||||
}
|
||||
else if (parentPrefix.Length > 0)
|
||||
{
|
||||
((SubjectTreeMeta<T>)shrunk).SetPrefix([.. parentPrefix, .. shrunk.Prefix]);
|
||||
}
|
||||
np = shrunk;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal recursive wildcard match
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static bool MatchNode(ISubjectTreeNode<T> n, byte[][] parts, byte[] pre, Func<byte[], T, bool> fn)
|
||||
{
|
||||
var hasFwc = parts.Length > 0 && parts[^1].Length == 1 && parts[^1][0] == SubjectTreeParts.Fwc;
|
||||
|
||||
while (n != null!)
|
||||
{
|
||||
var (nparts, matched) = n.MatchParts(parts);
|
||||
if (!matched) return true;
|
||||
|
||||
if (n.IsLeaf)
|
||||
{
|
||||
if (nparts.Length == 0 || (hasFwc && nparts.Length == 1))
|
||||
{
|
||||
var ln = (SubjectTreeLeaf<T>)n;
|
||||
if (!fn(ConcatBytes(pre, ln.Suffix), ln.Value)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Append this node's prefix to the running accumulator.
|
||||
var prefix = n.Prefix;
|
||||
if (prefix.Length > 0)
|
||||
pre = ConcatBytes(pre, prefix);
|
||||
|
||||
if (nparts.Length == 0 && !hasFwc)
|
||||
{
|
||||
// No parts remaining and no fwc — look for terminal matches.
|
||||
var hasTermPwc = parts.Length > 0 && parts[^1].Length == 1 && parts[^1][0] == SubjectTreeParts.Pwc;
|
||||
var termParts = hasTermPwc ? parts[^1..] : Array.Empty<byte[]>();
|
||||
|
||||
foreach (var cn in n.Children())
|
||||
{
|
||||
if (cn == null!) continue;
|
||||
if (cn.IsLeaf)
|
||||
{
|
||||
var ln = (SubjectTreeLeaf<T>)cn;
|
||||
if (ln.Suffix.Length == 0)
|
||||
{
|
||||
if (!fn(ConcatBytes(pre, ln.Suffix), ln.Value)) return false;
|
||||
}
|
||||
else if (hasTermPwc && Array.IndexOf(ln.Suffix, SubjectTreeParts.TSep) < 0)
|
||||
{
|
||||
if (!fn(ConcatBytes(pre, ln.Suffix), ln.Value)) return false;
|
||||
}
|
||||
}
|
||||
else if (hasTermPwc)
|
||||
{
|
||||
if (!MatchNode(cn, termParts, pre, fn)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Re-put the terminal fwc if nparts was exhausted by matching.
|
||||
if (hasFwc && nparts.Length == 0)
|
||||
nparts = parts[^1..];
|
||||
|
||||
var fp = nparts[0];
|
||||
var pByte = SubjectTreeParts.Pivot(fp, 0);
|
||||
|
||||
if (fp.Length == 1 && (pByte == SubjectTreeParts.Pwc || pByte == SubjectTreeParts.Fwc))
|
||||
{
|
||||
// Wildcard part — iterate all children.
|
||||
foreach (var cn in n.Children())
|
||||
{
|
||||
if (cn != null!)
|
||||
{
|
||||
if (!MatchNode(cn, nparts, pre, fn)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Literal part — find specific child and loop.
|
||||
var nextNode = n.FindChild(pByte);
|
||||
if (nextNode == null) return true;
|
||||
n = nextNode;
|
||||
parts = nparts;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal iteration
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static bool IterNode(ISubjectTreeNode<T> n, byte[] pre, bool ordered, Func<byte[], T, bool> fn)
|
||||
{
|
||||
if (n.IsLeaf)
|
||||
{
|
||||
var ln = (SubjectTreeLeaf<T>)n;
|
||||
return fn(ConcatBytes(pre, ln.Suffix), ln.Value);
|
||||
}
|
||||
|
||||
pre = ConcatBytes(pre, n.Prefix);
|
||||
|
||||
if (!ordered)
|
||||
{
|
||||
foreach (var cn in n.Children())
|
||||
{
|
||||
if (cn == null!) continue;
|
||||
if (!IterNode(cn, pre, false, fn)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ordered: sort children by their path bytes lexicographically.
|
||||
var children = n.Children().Where(c => c != null!).ToArray();
|
||||
Array.Sort(children, static (a, b) => a.Path.AsSpan().SequenceCompareTo(b.Path.AsSpan()));
|
||||
foreach (var cn in children)
|
||||
{
|
||||
if (!IterNode(cn, pre, true, fn)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Byte array helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
internal static byte[] ConcatBytes(byte[] a, byte[] b)
|
||||
{
|
||||
if (a.Length == 0) return b.Length == 0 ? Array.Empty<byte>() : b;
|
||||
if (b.Length == 0) return a;
|
||||
var result = new byte[a.Length + b.Length];
|
||||
a.CopyTo(result, 0);
|
||||
b.CopyTo(result, a.Length);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
// Copyright 2023-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
|
||||
// Internal node interface for the adaptive radix trie.
|
||||
internal interface ISubjectTreeNode<T>
|
||||
{
|
||||
bool IsLeaf { get; }
|
||||
byte[] Prefix { get; }
|
||||
void AddChild(byte key, ISubjectTreeNode<T> child);
|
||||
ISubjectTreeNode<T>? FindChild(byte key);
|
||||
void DeleteChild(byte key);
|
||||
bool IsFull { get; }
|
||||
ISubjectTreeNode<T> Grow();
|
||||
ISubjectTreeNode<T>? Shrink();
|
||||
ISubjectTreeNode<T>[] Children();
|
||||
int NumChildren { get; }
|
||||
byte[] Path { get; }
|
||||
(byte[][] remainingParts, bool matched) MatchParts(byte[][] parts);
|
||||
string Kind { get; }
|
||||
}
|
||||
|
||||
// Base class for non-leaf nodes, holding prefix and child count.
|
||||
internal abstract class SubjectTreeMeta<T> : ISubjectTreeNode<T>
|
||||
{
|
||||
protected byte[] _prefix;
|
||||
protected int _size;
|
||||
|
||||
protected SubjectTreeMeta(byte[] prefix)
|
||||
{
|
||||
_prefix = SubjectTreeParts.CopyBytes(prefix);
|
||||
}
|
||||
|
||||
public bool IsLeaf => false;
|
||||
public byte[] Prefix => _prefix;
|
||||
public int NumChildren => _size;
|
||||
public byte[] Path => _prefix;
|
||||
|
||||
public void SetPrefix(byte[] prefix)
|
||||
{
|
||||
_prefix = SubjectTreeParts.CopyBytes(prefix);
|
||||
}
|
||||
|
||||
public (byte[][] remainingParts, bool matched) MatchParts(byte[][] parts)
|
||||
=> SubjectTreeParts.MatchParts(parts, _prefix);
|
||||
|
||||
public abstract void AddChild(byte key, ISubjectTreeNode<T> child);
|
||||
public abstract ISubjectTreeNode<T>? FindChild(byte key);
|
||||
public abstract void DeleteChild(byte key);
|
||||
public abstract bool IsFull { get; }
|
||||
public abstract ISubjectTreeNode<T> Grow();
|
||||
public abstract ISubjectTreeNode<T>? Shrink();
|
||||
public abstract ISubjectTreeNode<T>[] Children();
|
||||
public abstract string Kind { get; }
|
||||
}
|
||||
|
||||
// Leaf node storing the terminal value plus a suffix byte[].
|
||||
internal sealed class SubjectTreeLeaf<T> : ISubjectTreeNode<T>
|
||||
{
|
||||
public T Value;
|
||||
public byte[] Suffix;
|
||||
|
||||
public SubjectTreeLeaf(byte[] suffix, T value)
|
||||
{
|
||||
Suffix = SubjectTreeParts.CopyBytes(suffix);
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public bool IsLeaf => true;
|
||||
public byte[] Prefix => Array.Empty<byte>();
|
||||
public int NumChildren => 0;
|
||||
public byte[] Path => Suffix;
|
||||
public string Kind => "LEAF";
|
||||
|
||||
public bool Match(ReadOnlySpan<byte> subject)
|
||||
=> subject.SequenceEqual(Suffix);
|
||||
|
||||
public void SetSuffix(byte[] suffix)
|
||||
=> Suffix = SubjectTreeParts.CopyBytes(suffix);
|
||||
|
||||
public bool IsFull => true;
|
||||
|
||||
public (byte[][] remainingParts, bool matched) MatchParts(byte[][] parts)
|
||||
=> SubjectTreeParts.MatchParts(parts, Suffix);
|
||||
|
||||
// Leaf nodes do not support child operations.
|
||||
public void AddChild(byte key, ISubjectTreeNode<T> child)
|
||||
=> throw new InvalidOperationException("AddChild called on leaf");
|
||||
|
||||
public ISubjectTreeNode<T>? FindChild(byte key)
|
||||
=> throw new InvalidOperationException("FindChild called on leaf");
|
||||
|
||||
public void DeleteChild(byte key)
|
||||
=> throw new InvalidOperationException("DeleteChild called on leaf");
|
||||
|
||||
public ISubjectTreeNode<T> Grow()
|
||||
=> throw new InvalidOperationException("Grow called on leaf");
|
||||
|
||||
public ISubjectTreeNode<T>? Shrink()
|
||||
=> throw new InvalidOperationException("Shrink called on leaf");
|
||||
|
||||
public ISubjectTreeNode<T>[] Children()
|
||||
=> Array.Empty<ISubjectTreeNode<T>>();
|
||||
}
|
||||
|
||||
// Node with up to 4 children (keys + children arrays, unsorted).
|
||||
internal sealed class SubjectTreeNode4<T> : SubjectTreeMeta<T>
|
||||
{
|
||||
private readonly byte[] _keys = new byte[4];
|
||||
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[4];
|
||||
|
||||
public SubjectTreeNode4(byte[] prefix) : base(prefix) { }
|
||||
|
||||
public override string Kind => "NODE4";
|
||||
|
||||
public override void AddChild(byte key, ISubjectTreeNode<T> child)
|
||||
{
|
||||
if (_size >= 4) throw new InvalidOperationException("node4 full!");
|
||||
_keys[_size] = key;
|
||||
_children[_size] = child;
|
||||
_size++;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>? FindChild(byte key)
|
||||
{
|
||||
for (var i = 0; i < _size; i++)
|
||||
{
|
||||
if (_keys[i] == key) return _children[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public override void DeleteChild(byte key)
|
||||
{
|
||||
for (var i = 0; i < _size; i++)
|
||||
{
|
||||
if (_keys[i] == key)
|
||||
{
|
||||
var last = _size - 1;
|
||||
if (i < last)
|
||||
{
|
||||
_keys[i] = _keys[last];
|
||||
_children[i] = _children[last];
|
||||
}
|
||||
_keys[last] = 0;
|
||||
_children[last] = null;
|
||||
_size--;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsFull => _size >= 4;
|
||||
|
||||
public override ISubjectTreeNode<T> Grow()
|
||||
{
|
||||
var nn = new SubjectTreeNode10<T>(_prefix);
|
||||
for (var i = 0; i < 4; i++)
|
||||
nn.AddChild(_keys[i], _children[i]!);
|
||||
return nn;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>? Shrink()
|
||||
{
|
||||
if (_size == 1) return _children[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>[] Children()
|
||||
{
|
||||
var result = new ISubjectTreeNode<T>[_size];
|
||||
for (var i = 0; i < _size; i++)
|
||||
result[i] = _children[i]!;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Internal access for tests.
|
||||
internal byte GetKey(int index) => _keys[index];
|
||||
internal ISubjectTreeNode<T>? GetChild(int index) => _children[index];
|
||||
}
|
||||
|
||||
// Node with up to 10 children (for numeric token segments).
|
||||
internal sealed class SubjectTreeNode10<T> : SubjectTreeMeta<T>
|
||||
{
|
||||
private readonly byte[] _keys = new byte[10];
|
||||
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[10];
|
||||
|
||||
public SubjectTreeNode10(byte[] prefix) : base(prefix) { }
|
||||
|
||||
public override string Kind => "NODE10";
|
||||
|
||||
public override void AddChild(byte key, ISubjectTreeNode<T> child)
|
||||
{
|
||||
if (_size >= 10) throw new InvalidOperationException("node10 full!");
|
||||
_keys[_size] = key;
|
||||
_children[_size] = child;
|
||||
_size++;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>? FindChild(byte key)
|
||||
{
|
||||
for (var i = 0; i < _size; i++)
|
||||
{
|
||||
if (_keys[i] == key) return _children[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public override void DeleteChild(byte key)
|
||||
{
|
||||
for (var i = 0; i < _size; i++)
|
||||
{
|
||||
if (_keys[i] == key)
|
||||
{
|
||||
var last = _size - 1;
|
||||
if (i < last)
|
||||
{
|
||||
_keys[i] = _keys[last];
|
||||
_children[i] = _children[last];
|
||||
}
|
||||
_keys[last] = 0;
|
||||
_children[last] = null;
|
||||
_size--;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsFull => _size >= 10;
|
||||
|
||||
public override ISubjectTreeNode<T> Grow()
|
||||
{
|
||||
var nn = new SubjectTreeNode16<T>(_prefix);
|
||||
for (var i = 0; i < _size; i++)
|
||||
nn.AddChild(_keys[i], _children[i]!);
|
||||
return nn;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>? Shrink()
|
||||
{
|
||||
if (_size > 4) return null;
|
||||
var nn = new SubjectTreeNode4<T>(Array.Empty<byte>());
|
||||
for (var i = 0; i < _size; i++)
|
||||
nn.AddChild(_keys[i], _children[i]!);
|
||||
return nn;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>[] Children()
|
||||
{
|
||||
var result = new ISubjectTreeNode<T>[_size];
|
||||
for (var i = 0; i < _size; i++)
|
||||
result[i] = _children[i]!;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Node with up to 16 children.
|
||||
internal sealed class SubjectTreeNode16<T> : SubjectTreeMeta<T>
|
||||
{
|
||||
private readonly byte[] _keys = new byte[16];
|
||||
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[16];
|
||||
|
||||
public SubjectTreeNode16(byte[] prefix) : base(prefix) { }
|
||||
|
||||
public override string Kind => "NODE16";
|
||||
|
||||
public override void AddChild(byte key, ISubjectTreeNode<T> child)
|
||||
{
|
||||
if (_size >= 16) throw new InvalidOperationException("node16 full!");
|
||||
_keys[_size] = key;
|
||||
_children[_size] = child;
|
||||
_size++;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>? FindChild(byte key)
|
||||
{
|
||||
for (var i = 0; i < _size; i++)
|
||||
{
|
||||
if (_keys[i] == key) return _children[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public override void DeleteChild(byte key)
|
||||
{
|
||||
for (var i = 0; i < _size; i++)
|
||||
{
|
||||
if (_keys[i] == key)
|
||||
{
|
||||
var last = _size - 1;
|
||||
if (i < last)
|
||||
{
|
||||
_keys[i] = _keys[last];
|
||||
_children[i] = _children[last];
|
||||
}
|
||||
_keys[last] = 0;
|
||||
_children[last] = null;
|
||||
_size--;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsFull => _size >= 16;
|
||||
|
||||
public override ISubjectTreeNode<T> Grow()
|
||||
{
|
||||
var nn = new SubjectTreeNode48<T>(_prefix);
|
||||
for (var i = 0; i < _size; i++)
|
||||
nn.AddChild(_keys[i], _children[i]!);
|
||||
return nn;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>? Shrink()
|
||||
{
|
||||
if (_size > 10) return null;
|
||||
var nn = new SubjectTreeNode10<T>(Array.Empty<byte>());
|
||||
for (var i = 0; i < _size; i++)
|
||||
nn.AddChild(_keys[i], _children[i]!);
|
||||
return nn;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>[] Children()
|
||||
{
|
||||
var result = new ISubjectTreeNode<T>[_size];
|
||||
for (var i = 0; i < _size; i++)
|
||||
result[i] = _children[i]!;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Node with up to 48 children, using a 256-byte key index (1-indexed, 0 means empty).
|
||||
internal sealed class SubjectTreeNode48<T> : SubjectTreeMeta<T>
|
||||
{
|
||||
// _keyIndex[byte] = 1-based index into _children; 0 means no entry.
|
||||
private readonly byte[] _keyIndex = new byte[256];
|
||||
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[48];
|
||||
|
||||
public SubjectTreeNode48(byte[] prefix) : base(prefix) { }
|
||||
|
||||
public override string Kind => "NODE48";
|
||||
|
||||
public override void AddChild(byte key, ISubjectTreeNode<T> child)
|
||||
{
|
||||
if (_size >= 48) throw new InvalidOperationException("node48 full!");
|
||||
_children[_size] = child;
|
||||
_keyIndex[key] = (byte)(_size + 1); // 1-indexed
|
||||
_size++;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>? FindChild(byte key)
|
||||
{
|
||||
var i = _keyIndex[key];
|
||||
if (i == 0) return null;
|
||||
return _children[i - 1];
|
||||
}
|
||||
|
||||
public override void DeleteChild(byte key)
|
||||
{
|
||||
var i = _keyIndex[key];
|
||||
if (i == 0) return;
|
||||
i--; // Convert from 1-indexed
|
||||
var last = _size - 1;
|
||||
if (i < last)
|
||||
{
|
||||
_children[i] = _children[last];
|
||||
// Find which key index points to 'last' and redirect it to 'i'.
|
||||
for (var ic = 0; ic < 256; ic++)
|
||||
{
|
||||
if (_keyIndex[ic] == last + 1)
|
||||
{
|
||||
_keyIndex[ic] = (byte)(i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_children[last] = null;
|
||||
_keyIndex[key] = 0;
|
||||
_size--;
|
||||
}
|
||||
|
||||
public override bool IsFull => _size >= 48;
|
||||
|
||||
public override ISubjectTreeNode<T> Grow()
|
||||
{
|
||||
var nn = new SubjectTreeNode256<T>(_prefix);
|
||||
for (var c = 0; c < 256; c++)
|
||||
{
|
||||
var i = _keyIndex[c];
|
||||
if (i > 0)
|
||||
nn.AddChild((byte)c, _children[i - 1]!);
|
||||
}
|
||||
return nn;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>? Shrink()
|
||||
{
|
||||
if (_size > 16) return null;
|
||||
var nn = new SubjectTreeNode16<T>(Array.Empty<byte>());
|
||||
for (var c = 0; c < 256; c++)
|
||||
{
|
||||
var i = _keyIndex[c];
|
||||
if (i > 0)
|
||||
nn.AddChild((byte)c, _children[i - 1]!);
|
||||
}
|
||||
return nn;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>[] Children()
|
||||
{
|
||||
var result = new ISubjectTreeNode<T>[_size];
|
||||
var idx = 0;
|
||||
for (var i = 0; i < _size; i++)
|
||||
{
|
||||
if (_children[i] != null)
|
||||
result[idx++] = _children[i]!;
|
||||
}
|
||||
return result[..idx];
|
||||
}
|
||||
|
||||
// Internal access for tests.
|
||||
internal byte GetKeyIndex(int key) => _keyIndex[key];
|
||||
internal ISubjectTreeNode<T>? GetChildAt(int index) => _children[index];
|
||||
}
|
||||
|
||||
// Node with 256 children, indexed directly by byte value.
|
||||
internal sealed class SubjectTreeNode256<T> : SubjectTreeMeta<T>
|
||||
{
|
||||
private readonly ISubjectTreeNode<T>?[] _children = new ISubjectTreeNode<T>?[256];
|
||||
|
||||
public SubjectTreeNode256(byte[] prefix) : base(prefix) { }
|
||||
|
||||
public override string Kind => "NODE256";
|
||||
|
||||
public override void AddChild(byte key, ISubjectTreeNode<T> child)
|
||||
{
|
||||
_children[key] = child;
|
||||
_size++;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>? FindChild(byte key)
|
||||
=> _children[key];
|
||||
|
||||
public override void DeleteChild(byte key)
|
||||
{
|
||||
if (_children[key] != null)
|
||||
{
|
||||
_children[key] = null;
|
||||
_size--;
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsFull => false;
|
||||
|
||||
public override ISubjectTreeNode<T> Grow()
|
||||
=> throw new InvalidOperationException("Grow cannot be called on node256");
|
||||
|
||||
public override ISubjectTreeNode<T>? Shrink()
|
||||
{
|
||||
if (_size > 48) return null;
|
||||
var nn = new SubjectTreeNode48<T>(Array.Empty<byte>());
|
||||
for (var c = 0; c < 256; c++)
|
||||
{
|
||||
if (_children[c] != null)
|
||||
nn.AddChild((byte)c, _children[c]!);
|
||||
}
|
||||
return nn;
|
||||
}
|
||||
|
||||
public override ISubjectTreeNode<T>[] Children()
|
||||
=> _children.Where(c => c != null).Select(c => c!).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Copyright 2023-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
|
||||
/// <summary>
|
||||
/// Utility methods for NATS subject matching, wildcard part decomposition,
|
||||
/// common prefix computation, and byte manipulation used by SubjectTree.
|
||||
/// </summary>
|
||||
internal static class SubjectTreeParts
|
||||
{
|
||||
// NATS subject special bytes.
|
||||
internal const byte Pwc = (byte)'*'; // single-token wildcard
|
||||
internal const byte Fwc = (byte)'>'; // full wildcard (terminal)
|
||||
internal const byte TSep = (byte)'.'; // token separator
|
||||
|
||||
// Sentinel pivot returned when subject position is past end.
|
||||
internal const byte NoPivot = 127;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pivot byte at <paramref name="pos"/> in <paramref name="subject"/>,
|
||||
/// or <see cref="NoPivot"/> if the position is at or beyond the end.
|
||||
/// </summary>
|
||||
internal static byte Pivot(ReadOnlySpan<byte> subject, int pos)
|
||||
=> pos >= subject.Length ? NoPivot : subject[pos];
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pivot byte at <paramref name="pos"/> in <paramref name="subject"/>,
|
||||
/// or <see cref="NoPivot"/> if the position is at or beyond the end.
|
||||
/// </summary>
|
||||
internal static byte Pivot(byte[] subject, int pos)
|
||||
=> pos >= subject.Length ? NoPivot : subject[pos];
|
||||
|
||||
/// <summary>
|
||||
/// Computes the number of leading bytes that are equal between two spans.
|
||||
/// </summary>
|
||||
internal static int CommonPrefixLen(ReadOnlySpan<byte> s1, ReadOnlySpan<byte> s2)
|
||||
{
|
||||
var limit = Math.Min(s1.Length, s2.Length);
|
||||
var i = 0;
|
||||
while (i < limit && s1[i] == s2[i])
|
||||
i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of <paramref name="src"/>, or an empty array if src is empty.
|
||||
/// </summary>
|
||||
internal static byte[] CopyBytes(ReadOnlySpan<byte> src)
|
||||
{
|
||||
if (src.IsEmpty) return Array.Empty<byte>();
|
||||
return src.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of <paramref name="src"/>, or an empty array if src is null or empty.
|
||||
/// </summary>
|
||||
internal static byte[] CopyBytes(byte[]? src)
|
||||
{
|
||||
if (src == null || src.Length == 0) return Array.Empty<byte>();
|
||||
var dst = new byte[src.Length];
|
||||
src.CopyTo(dst, 0);
|
||||
return dst;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a byte array to a string using Latin-1 (ISO-8859-1) encoding,
|
||||
/// which preserves a 1:1 byte-to-char mapping for all byte values 0-255.
|
||||
/// </summary>
|
||||
internal static string BytesToString(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length == 0) return string.Empty;
|
||||
return System.Text.Encoding.Latin1.GetString(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Breaks a filter subject into parts separated by wildcards ('*' and '>').
|
||||
/// Each literal segment between wildcards becomes one part; each wildcard
|
||||
/// becomes its own single-byte part.
|
||||
/// </summary>
|
||||
internal static byte[][] GenParts(byte[] filter)
|
||||
{
|
||||
var parts = new List<byte[]>(8);
|
||||
var start = 0;
|
||||
var e = filter.Length - 1;
|
||||
|
||||
for (var i = 0; i < filter.Length; i++)
|
||||
{
|
||||
if (filter[i] == TSep)
|
||||
{
|
||||
// Check if next token is pwc (internal or terminal).
|
||||
if (i < e && filter[i + 1] == Pwc &&
|
||||
((i + 2 <= e && filter[i + 2] == TSep) || i + 1 == e))
|
||||
{
|
||||
if (i > start)
|
||||
parts.Add(filter[start..(i + 1)]);
|
||||
parts.Add(filter[(i + 1)..(i + 2)]);
|
||||
i++; // skip pwc
|
||||
if (i + 2 <= e)
|
||||
i++; // skip next tsep from next part
|
||||
start = i + 1;
|
||||
}
|
||||
else if (i < e && filter[i + 1] == Fwc && i + 1 == e)
|
||||
{
|
||||
if (i > start)
|
||||
parts.Add(filter[start..(i + 1)]);
|
||||
parts.Add(filter[(i + 1)..(i + 2)]);
|
||||
i++; // skip fwc
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
else if (filter[i] == Pwc || filter[i] == Fwc)
|
||||
{
|
||||
// Wildcard must be preceded by tsep (or be at start).
|
||||
var prev = i - 1;
|
||||
if (prev >= 0 && filter[prev] != TSep)
|
||||
continue;
|
||||
|
||||
// Wildcard must be at end or followed by tsep.
|
||||
var next = i + 1;
|
||||
if (next == e || (next < e && filter[next] != TSep))
|
||||
continue;
|
||||
|
||||
// Full wildcard must be terminal.
|
||||
if (filter[i] == Fwc && i < e)
|
||||
break;
|
||||
|
||||
// Leading wildcard.
|
||||
parts.Add(filter[i..(i + 1)]);
|
||||
if (i + 1 <= e)
|
||||
i++; // skip next tsep
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (start < filter.Length)
|
||||
{
|
||||
// Eat leading tsep if present.
|
||||
if (filter[start] == TSep)
|
||||
start++;
|
||||
if (start < filter.Length)
|
||||
parts.Add(filter[start..]);
|
||||
}
|
||||
|
||||
return parts.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches parts against a fragment (prefix or suffix).
|
||||
/// Returns the remaining parts and whether matching succeeded.
|
||||
/// </summary>
|
||||
internal static (byte[][] remainingParts, bool matched) MatchParts(byte[][] parts, byte[] frag)
|
||||
{
|
||||
var lf = frag.Length;
|
||||
if (lf == 0) return (parts, true);
|
||||
|
||||
var si = 0;
|
||||
var lpi = parts.Length - 1;
|
||||
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
if (si >= lf)
|
||||
return (parts[i..], true);
|
||||
|
||||
var part = parts[i];
|
||||
var lp = part.Length;
|
||||
|
||||
// Check for wildcard placeholders.
|
||||
if (lp == 1)
|
||||
{
|
||||
if (part[0] == Pwc)
|
||||
{
|
||||
// Find the next token separator.
|
||||
var index = Array.IndexOf(frag, TSep, si);
|
||||
if (index < 0)
|
||||
{
|
||||
// No tsep found.
|
||||
if (i == lpi)
|
||||
return (Array.Empty<byte[]>(), true);
|
||||
return (parts[i..], true);
|
||||
}
|
||||
si = index + 1;
|
||||
continue;
|
||||
}
|
||||
else if (part[0] == Fwc)
|
||||
{
|
||||
return (Array.Empty<byte[]>(), true);
|
||||
}
|
||||
}
|
||||
|
||||
var end = Math.Min(si + lp, lf);
|
||||
// If part is larger than the remaining fragment, adjust.
|
||||
var comparePart = part;
|
||||
if (si + lp > end)
|
||||
comparePart = part[..(end - si)];
|
||||
|
||||
if (!frag.AsSpan(si, end - si).SequenceEqual(comparePart))
|
||||
return (parts, false);
|
||||
|
||||
// Fragment still has bytes left.
|
||||
if (end < lf)
|
||||
{
|
||||
si = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
// We matched a partial part.
|
||||
if (end < si + lp)
|
||||
{
|
||||
if (end >= lf)
|
||||
{
|
||||
// Create a copy of parts with the current part trimmed.
|
||||
var newParts = new byte[parts.Length][];
|
||||
parts.CopyTo(newParts, 0);
|
||||
newParts[i] = parts[i][(lf - si)..];
|
||||
return (newParts[i..], true);
|
||||
}
|
||||
else
|
||||
{
|
||||
return (parts[(i + 1)..], true);
|
||||
}
|
||||
}
|
||||
|
||||
if (i == lpi)
|
||||
return (Array.Empty<byte[]>(), true);
|
||||
|
||||
si += part.Length;
|
||||
}
|
||||
|
||||
return (parts, false);
|
||||
}
|
||||
}
|
||||
64
dotnet/src/ZB.MOM.NatsNet.Server/Internal/ElasticPointer.cs
Normal file
64
dotnet/src/ZB.MOM.NatsNet.Server/Internal/ElasticPointer.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
namespace ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// A pointer that can be toggled between weak and strong references, allowing
|
||||
/// the garbage collector to reclaim the target when weakened.
|
||||
/// Mirrors the Go <c>elastic.Pointer[T]</c> type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the referenced object. Must be a reference type.</typeparam>
|
||||
public sealed class ElasticPointer<T> where T : class
|
||||
{
|
||||
private WeakReference<T>? _weak;
|
||||
private T? _strong;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="ElasticPointer{T}"/> holding a weak reference to <paramref name="value"/>.
|
||||
/// </summary>
|
||||
public static ElasticPointer<T> Make(T value)
|
||||
{
|
||||
return new ElasticPointer<T> { _weak = new WeakReference<T>(value) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the target. If the pointer is currently strengthened, the strong reference is updated too.
|
||||
/// </summary>
|
||||
public void Set(T value)
|
||||
{
|
||||
_weak = new WeakReference<T>(value);
|
||||
if (_strong != null)
|
||||
_strong = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotes to a strong reference, preventing the GC from collecting the target.
|
||||
/// No-op if already strengthened or if the weak target has been collected.
|
||||
/// </summary>
|
||||
public void Strengthen()
|
||||
{
|
||||
if (_strong != null)
|
||||
return;
|
||||
if (_weak != null && _weak.TryGetTarget(out var target))
|
||||
_strong = target;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverts to a weak reference, allowing the GC to reclaim the target.
|
||||
/// No-op if already weakened.
|
||||
/// </summary>
|
||||
public void Weaken()
|
||||
{
|
||||
_strong = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the target value, or <see langword="null"/> if the weak reference has been collected.
|
||||
/// </summary>
|
||||
public T? Value()
|
||||
{
|
||||
if (_strong != null)
|
||||
return _strong;
|
||||
if (_weak != null && _weak.TryGetTarget(out var target))
|
||||
return target;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Provides cross-platform process CPU and memory usage statistics.
|
||||
/// Mirrors the Go <c>pse</c> (Process Status Emulation) package, replacing
|
||||
/// per-platform implementations (rusage, /proc/stat, PDH) with
|
||||
/// <see cref="System.Diagnostics.Process"/>.
|
||||
/// </summary>
|
||||
public static class ProcessStatsProvider
|
||||
{
|
||||
private static readonly Process _self = Process.GetCurrentProcess();
|
||||
private static readonly int _processorCount = Environment.ProcessorCount;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
private static TimeSpan _lastCpuTime;
|
||||
private static DateTime _lastSampleTime;
|
||||
private static double _cachedPcpu;
|
||||
private static long _cachedRss;
|
||||
private static long _cachedVss;
|
||||
|
||||
static ProcessStatsProvider()
|
||||
{
|
||||
UpdateUsage();
|
||||
StartPeriodicSampling();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current process CPU percentage, RSS (bytes), and VSS (bytes).
|
||||
/// Values are refreshed approximately every second by a background timer.
|
||||
/// </summary>
|
||||
/// <param name="pcpu">Percent CPU utilization (0–100 × core count).</param>
|
||||
/// <param name="rss">Resident set size in bytes.</param>
|
||||
/// <param name="vss">Virtual memory size in bytes.</param>
|
||||
public static void ProcUsage(out double pcpu, out long rss, out long vss)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
pcpu = _cachedPcpu;
|
||||
rss = _cachedRss;
|
||||
vss = _cachedVss;
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateUsage()
|
||||
{
|
||||
try
|
||||
{
|
||||
_self.Refresh();
|
||||
var now = DateTime.UtcNow;
|
||||
var cpuTime = _self.TotalProcessorTime;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var elapsed = now - _lastSampleTime;
|
||||
if (elapsed >= TimeSpan.FromMilliseconds(500))
|
||||
{
|
||||
var cpuDelta = (cpuTime - _lastCpuTime).TotalSeconds;
|
||||
// Normalize against elapsed wall time.
|
||||
// Result is 0–100; does not multiply by ProcessorCount to match Go behaviour.
|
||||
_cachedPcpu = elapsed.TotalSeconds > 0
|
||||
? Math.Round(cpuDelta / elapsed.TotalSeconds * 1000.0) / 10.0
|
||||
: 0;
|
||||
_lastSampleTime = now;
|
||||
_lastCpuTime = cpuTime;
|
||||
}
|
||||
|
||||
_cachedRss = _self.WorkingSet64;
|
||||
_cachedVss = _self.VirtualMemorySize64;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Suppress — diagnostics should never crash the server.
|
||||
}
|
||||
}
|
||||
|
||||
private static void StartPeriodicSampling()
|
||||
{
|
||||
var timer = new Timer(_ => UpdateUsage(), null,
|
||||
dueTime: TimeSpan.FromSeconds(1),
|
||||
period: TimeSpan.FromSeconds(1));
|
||||
|
||||
// Keep timer alive for the process lifetime.
|
||||
GC.KeepAlive(timer);
|
||||
}
|
||||
|
||||
// --- Windows PDH helpers (replaced by Process class in .NET) ---
|
||||
// The following methods exist to satisfy the porting mapping but delegate
|
||||
// to the cross-platform Process API above.
|
||||
|
||||
internal static string GetProcessImageName() =>
|
||||
Path.GetFileNameWithoutExtension(Environment.ProcessPath ?? _self.ProcessName);
|
||||
|
||||
internal static void InitCounters()
|
||||
{
|
||||
// No-op: .NET Process class initializes lazily.
|
||||
}
|
||||
|
||||
internal static double PdhOpenQuery() => 0; // Mapped to Process API.
|
||||
internal static double PdhAddCounter() => 0;
|
||||
internal static double PdhCollectQueryData() => 0;
|
||||
internal static double PdhGetFormattedCounterArrayDouble() => 0;
|
||||
internal static double GetCounterArrayData() => 0;
|
||||
}
|
||||
95
dotnet/src/ZB.MOM.NatsNet.Server/Internal/SystemMemory.cs
Normal file
95
dotnet/src/ZB.MOM.NatsNet.Server/Internal/SystemMemory.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Returns total physical memory available to the system in bytes.
|
||||
/// Mirrors the Go <c>sysmem</c> package with platform-specific implementations.
|
||||
/// Returns 0 if the value cannot be determined on the current platform.
|
||||
/// </summary>
|
||||
public static class SystemMemory
|
||||
{
|
||||
/// <summary>Returns total physical memory in bytes, or 0 on failure.</summary>
|
||||
public static long Memory()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return MemoryWindows();
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
return MemoryDarwin();
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
return MemoryLinux();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- macOS ---
|
||||
|
||||
internal static long MemoryDarwin() => SysctlInt64("hw.memsize");
|
||||
|
||||
/// <summary>
|
||||
/// Reads an int64 sysctl value by name on BSD-derived systems (macOS, FreeBSD, etc.).
|
||||
/// </summary>
|
||||
internal static unsafe long SysctlInt64(string name)
|
||||
{
|
||||
var size = (nuint)sizeof(long);
|
||||
long value = 0;
|
||||
var ret = sysctlbyname(name, &value, &size, IntPtr.Zero, 0);
|
||||
return ret == 0 ? value : 0;
|
||||
}
|
||||
|
||||
[DllImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)]
|
||||
private static extern unsafe int sysctlbyname(
|
||||
string name,
|
||||
void* oldp,
|
||||
nuint* oldlenp,
|
||||
IntPtr newp,
|
||||
nuint newlen);
|
||||
|
||||
// --- Linux ---
|
||||
|
||||
internal static long MemoryLinux()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Parse MemTotal from /proc/meminfo (value is in kB).
|
||||
foreach (var line in File.ReadLines("/proc/meminfo"))
|
||||
{
|
||||
if (!line.StartsWith("MemTotal:", StringComparison.Ordinal))
|
||||
continue;
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2 && long.TryParse(parts[1], out var kb))
|
||||
return kb * 1024L;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to return 0.
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Windows ---
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MemoryStatusEx
|
||||
{
|
||||
public uint dwLength;
|
||||
public uint dwMemoryLoad;
|
||||
public ulong ullTotalPhys;
|
||||
public ulong ullAvailPhys;
|
||||
public ulong ullTotalPageFile;
|
||||
public ulong ullAvailPageFile;
|
||||
public ulong ullTotalVirtual;
|
||||
public ulong ullAvailVirtual;
|
||||
public ulong ullAvailExtendedVirtual;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GlobalMemoryStatusEx(ref MemoryStatusEx lpBuffer);
|
||||
|
||||
internal static long MemoryWindows()
|
||||
{
|
||||
var msx = new MemoryStatusEx { dwLength = (uint)Marshal.SizeOf<MemoryStatusEx>() };
|
||||
return GlobalMemoryStatusEx(ref msx) ? (long)msx.ullTotalPhys : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/parser.go and server/client.go in the NATS server Go source.
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for the protocol handler callbacks invoked by <see cref="ProtocolParser.Parse"/>.
|
||||
/// Decouples the state machine from the client implementation.
|
||||
/// The client connection will implement this interface in later sessions.
|
||||
/// </summary>
|
||||
public interface IProtocolHandler
|
||||
{
|
||||
// ---- Dynamic connection state ----
|
||||
|
||||
bool IsMqtt { get; }
|
||||
bool Trace { get; }
|
||||
bool HasMappings { get; }
|
||||
bool IsAwaitingAuth { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to register the no-auth user for this connection.
|
||||
/// Returns true if a no-auth user was found and registered (allowing parse to continue).
|
||||
/// </summary>
|
||||
bool TryRegisterNoAuthUser();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this is a gateway inbound connection that has not yet received CONNECT.
|
||||
/// </summary>
|
||||
bool IsGatewayInboundNotConnected { get; }
|
||||
|
||||
// ---- Protocol action handlers ----
|
||||
|
||||
Exception? ProcessConnect(byte[] arg);
|
||||
Exception? ProcessInfo(byte[] arg);
|
||||
void ProcessPing();
|
||||
void ProcessPong();
|
||||
void ProcessErr(string arg);
|
||||
|
||||
// ---- Sub/unsub handlers (kind-specific) ----
|
||||
|
||||
Exception? ProcessClientSub(byte[] arg);
|
||||
Exception? ProcessClientUnsub(byte[] arg);
|
||||
Exception? ProcessRemoteSub(byte[] arg, bool isLeaf);
|
||||
Exception? ProcessRemoteUnsub(byte[] arg, bool isLeafUnsub);
|
||||
Exception? ProcessGatewayRSub(byte[] arg);
|
||||
Exception? ProcessGatewayRUnsub(byte[] arg);
|
||||
Exception? ProcessLeafSub(byte[] arg);
|
||||
Exception? ProcessLeafUnsub(byte[] arg);
|
||||
Exception? ProcessAccountSub(byte[] arg);
|
||||
void ProcessAccountUnsub(byte[] arg);
|
||||
|
||||
// ---- Message processing ----
|
||||
|
||||
void ProcessInboundMsg(byte[] msg);
|
||||
bool SelectMappedSubject();
|
||||
|
||||
// ---- Tracing ----
|
||||
|
||||
void TraceInOp(string name, byte[]? arg);
|
||||
void TraceMsg(byte[] msg);
|
||||
|
||||
// ---- Error handling ----
|
||||
|
||||
void SendErr(string msg);
|
||||
void AuthViolation();
|
||||
void CloseConnection(int reason);
|
||||
string KindString();
|
||||
}
|
||||
171
dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ParserTypes.cs
Normal file
171
dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ParserTypes.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
// Copyright 2012-2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/parser.go in the NATS server Go source.
|
||||
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Parser state machine states.
|
||||
/// Mirrors the Go <c>parserState</c> const block in parser.go (79 states).
|
||||
/// </summary>
|
||||
public enum ParserState
|
||||
{
|
||||
OpStart = 0,
|
||||
OpPlus,
|
||||
OpPlusO,
|
||||
OpPlusOk,
|
||||
OpMinus,
|
||||
OpMinusE,
|
||||
OpMinusEr,
|
||||
OpMinusErr,
|
||||
OpMinusErrSpc,
|
||||
MinusErrArg,
|
||||
OpC,
|
||||
OpCo,
|
||||
OpCon,
|
||||
OpConn,
|
||||
OpConne,
|
||||
OpConnec,
|
||||
OpConnect,
|
||||
ConnectArg,
|
||||
OpH,
|
||||
OpHp,
|
||||
OpHpu,
|
||||
OpHpub,
|
||||
OpHpubSpc,
|
||||
HpubArg,
|
||||
OpHm,
|
||||
OpHms,
|
||||
OpHmsg,
|
||||
OpHmsgSpc,
|
||||
HmsgArg,
|
||||
OpP,
|
||||
OpPu,
|
||||
OpPub,
|
||||
OpPubSpc,
|
||||
PubArg,
|
||||
OpPi,
|
||||
OpPin,
|
||||
OpPing,
|
||||
OpPo,
|
||||
OpPon,
|
||||
OpPong,
|
||||
MsgPayload,
|
||||
MsgEndR,
|
||||
MsgEndN,
|
||||
OpS,
|
||||
OpSu,
|
||||
OpSub,
|
||||
OpSubSpc,
|
||||
SubArg,
|
||||
OpA,
|
||||
OpAsub,
|
||||
OpAsubSpc,
|
||||
AsubArg,
|
||||
OpAusub,
|
||||
OpAusubSpc,
|
||||
AusubArg,
|
||||
OpL,
|
||||
OpLs,
|
||||
OpR,
|
||||
OpRs,
|
||||
OpU,
|
||||
OpUn,
|
||||
OpUns,
|
||||
OpUnsu,
|
||||
OpUnsub,
|
||||
OpUnsubSpc,
|
||||
UnsubArg,
|
||||
OpM,
|
||||
OpMs,
|
||||
OpMsg,
|
||||
OpMsgSpc,
|
||||
MsgArg,
|
||||
OpI,
|
||||
OpIn,
|
||||
OpInf,
|
||||
OpInfo,
|
||||
InfoArg,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed publish/message arguments.
|
||||
/// Mirrors Go <c>pubArg</c> struct in parser.go.
|
||||
/// </summary>
|
||||
public sealed class PublishArgument
|
||||
{
|
||||
public byte[]? Arg { get; set; }
|
||||
public byte[]? PaCache { get; set; }
|
||||
public byte[]? Origin { get; set; }
|
||||
public byte[]? Account { get; set; }
|
||||
public byte[]? Subject { get; set; }
|
||||
public byte[]? Deliver { get; set; }
|
||||
public byte[]? Mapped { get; set; }
|
||||
public byte[]? Reply { get; set; }
|
||||
public byte[]? SizeBytes { get; set; }
|
||||
public byte[]? HeaderBytes { get; set; }
|
||||
public List<byte[]>? Queues { get; set; }
|
||||
public int Size { get; set; }
|
||||
public int HeaderSize { get; set; } = -1;
|
||||
public bool Delivered { get; set; }
|
||||
|
||||
/// <summary>Resets all fields to their defaults.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
Arg = null;
|
||||
PaCache = null;
|
||||
Origin = null;
|
||||
Account = null;
|
||||
Subject = null;
|
||||
Deliver = null;
|
||||
Mapped = null;
|
||||
Reply = null;
|
||||
SizeBytes = null;
|
||||
HeaderBytes = null;
|
||||
Queues = null;
|
||||
Size = 0;
|
||||
HeaderSize = -1;
|
||||
Delivered = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holds the parser state for a single connection.
|
||||
/// Mirrors Go <c>parseState</c> struct embedded in <c>client</c>.
|
||||
/// </summary>
|
||||
public sealed class ParseContext
|
||||
{
|
||||
// ---- Parser state ----
|
||||
|
||||
public ParserState State { get; set; }
|
||||
public byte Op { get; set; }
|
||||
public int ArgStart { get; set; }
|
||||
public int Drop { get; set; }
|
||||
public PublishArgument Pa { get; } = new();
|
||||
public byte[]? ArgBuf { get; set; }
|
||||
public byte[]? MsgBuf { get; set; }
|
||||
|
||||
// ---- Connection-level properties (set once at creation) ----
|
||||
|
||||
public ClientKind Kind { get; set; }
|
||||
public int MaxControlLine { get; set; } = ServerConstants.MaxControlLineSize;
|
||||
public int MaxPayload { get; set; } = -1;
|
||||
public bool HasHeaders { get; set; }
|
||||
|
||||
// ---- Internal scratch buffer ----
|
||||
|
||||
internal byte[] Scratch { get; } = new byte[ServerConstants.MaxControlLineSize];
|
||||
}
|
||||
1255
dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ProtocolParser.cs
Normal file
1255
dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ProtocolParser.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Auth.CertificateIdentityProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the certidp module, mirroring certidp_test.go and ocsp_responder_test.go.
|
||||
/// </summary>
|
||||
public sealed class CertificateIdentityProviderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, "good")]
|
||||
[InlineData(1, "revoked")]
|
||||
[InlineData(2, "unknown")]
|
||||
[InlineData(42, "unknown")] // Invalid → defaults to unknown (never good)
|
||||
public void GetStatusAssertionStr_ShouldMapCorrectly(int input, string expected)
|
||||
{
|
||||
// Mirror: TestGetStatusAssertionStr
|
||||
OcspStatusAssertionExtensions.GetStatusAssertionStr(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeOCSPRequest_ShouldProduceUrlSafeBase64()
|
||||
{
|
||||
// Mirror: TestEncodeOCSPRequest
|
||||
var data = "test data for OCSP request"u8.ToArray();
|
||||
var encoded = OcspResponder.EncodeOCSPRequest(data);
|
||||
|
||||
// Should not contain unescaped base64 chars that are URL-unsafe.
|
||||
encoded.ShouldNotContain("+");
|
||||
encoded.ShouldNotContain("/");
|
||||
encoded.ShouldNotContain("=");
|
||||
|
||||
// Should round-trip: URL-unescape → base64-decode → original bytes.
|
||||
var unescaped = Uri.UnescapeDataString(encoded);
|
||||
var decoded = Convert.FromBase64String(unescaped);
|
||||
decoded.ShouldBe(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="AccessTimeService"/>, mirroring ats_test.go.
|
||||
/// </summary>
|
||||
[Collection("AccessTimeService")]
|
||||
public sealed class AccessTimeServiceTests : IDisposable
|
||||
{
|
||||
public AccessTimeServiceTests()
|
||||
{
|
||||
AccessTimeService.Reset();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AccessTimeService.Reset();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotRunningValue_ShouldReturnNonZero()
|
||||
{
|
||||
// Mirror: TestNotRunningValue
|
||||
// No registrants; AccessTime() must still return a non-zero value.
|
||||
var at = AccessTimeService.AccessTime();
|
||||
at.ShouldBeGreaterThan(0);
|
||||
|
||||
// Should be stable (no background timer updating it).
|
||||
var atn = AccessTimeService.AccessTime();
|
||||
atn.ShouldBe(at);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterAndUnregister_ShouldManageLifetime()
|
||||
{
|
||||
// Mirror: TestRegisterAndUnregister
|
||||
|
||||
AccessTimeService.Register();
|
||||
|
||||
var at = AccessTimeService.AccessTime();
|
||||
at.ShouldBeGreaterThan(0);
|
||||
|
||||
// Background timer should update the time.
|
||||
await Task.Delay(AccessTimeService.TickInterval * 3);
|
||||
var atn = AccessTimeService.AccessTime();
|
||||
atn.ShouldBeGreaterThan(at);
|
||||
|
||||
// Unregister; timer should stop.
|
||||
AccessTimeService.Unregister();
|
||||
await Task.Delay(AccessTimeService.TickInterval);
|
||||
|
||||
at = AccessTimeService.AccessTime();
|
||||
await Task.Delay(AccessTimeService.TickInterval * 3);
|
||||
atn = AccessTimeService.AccessTime();
|
||||
atn.ShouldBe(at);
|
||||
|
||||
// Re-register should restart the timer.
|
||||
AccessTimeService.Register();
|
||||
try
|
||||
{
|
||||
at = AccessTimeService.AccessTime();
|
||||
await Task.Delay(AccessTimeService.TickInterval * 3);
|
||||
atn = AccessTimeService.AccessTime();
|
||||
atn.ShouldBeGreaterThan(at);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AccessTimeService.Unregister();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnbalancedUnregister_ShouldThrow()
|
||||
{
|
||||
// Mirror: TestUnbalancedUnregister
|
||||
Should.Throw<InvalidOperationException>(() => AccessTimeService.Unregister());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
// Copyright 2025 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Internal.DataStructures;
|
||||
|
||||
/// <summary>
|
||||
/// Ports all 21 tests from Go's gsl/gsl_test.go.
|
||||
/// </summary>
|
||||
public sealed class GenericSublistTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers (mirror Go's require_* functions)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Counts how many values the sublist matches for <paramref name="subject"/>
|
||||
/// and asserts that count equals <paramref name="expected"/>.
|
||||
/// Mirrors Go's <c>require_Matches</c>.
|
||||
/// </summary>
|
||||
private static void RequireMatches<T>(GenericSublist<T> s, string subject, int expected)
|
||||
where T : notnull
|
||||
{
|
||||
var matches = 0;
|
||||
s.Match(subject, _ => matches++);
|
||||
matches.ShouldBe(expected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistInit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistInit()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
s.Count.ShouldBe(0u);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistInsertCount
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistInsertCount()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
s.Insert("foo", EmptyStruct.Value);
|
||||
s.Insert("bar", EmptyStruct.Value);
|
||||
s.Insert("foo.bar", EmptyStruct.Value);
|
||||
s.Count.ShouldBe(3u);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistSimple
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistSimple()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
s.Insert("foo", EmptyStruct.Value);
|
||||
RequireMatches(s, "foo", 1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistSimpleMultiTokens
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistSimpleMultiTokens()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
s.Insert("foo.bar.baz", EmptyStruct.Value);
|
||||
RequireMatches(s, "foo.bar.baz", 1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistPartialWildcard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistPartialWildcard()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
s.Insert("a.b.c", EmptyStruct.Value);
|
||||
s.Insert("a.*.c", EmptyStruct.Value);
|
||||
RequireMatches(s, "a.b.c", 2);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistPartialWildcardAtEnd
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistPartialWildcardAtEnd()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
s.Insert("a.b.c", EmptyStruct.Value);
|
||||
s.Insert("a.b.*", EmptyStruct.Value);
|
||||
RequireMatches(s, "a.b.c", 2);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistFullWildcard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistFullWildcard()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
s.Insert("a.b.c", EmptyStruct.Value);
|
||||
s.Insert("a.>", EmptyStruct.Value);
|
||||
RequireMatches(s, "a.b.c", 2);
|
||||
RequireMatches(s, "a.>", 1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistRemove
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistRemove()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
|
||||
s.Insert("a.b.c.d", EmptyStruct.Value);
|
||||
s.Count.ShouldBe(1u);
|
||||
RequireMatches(s, "a.b.c.d", 1);
|
||||
|
||||
s.Remove("a.b.c.d", EmptyStruct.Value);
|
||||
s.Count.ShouldBe(0u);
|
||||
RequireMatches(s, "a.b.c.d", 0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistRemoveWildcard
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistRemoveWildcard()
|
||||
{
|
||||
var s = GenericSublist<int>.NewSublist();
|
||||
|
||||
s.Insert("a.b.c.d", 11);
|
||||
s.Insert("a.b.*.d", 22);
|
||||
s.Insert("a.b.>", 33);
|
||||
s.Count.ShouldBe(3u);
|
||||
RequireMatches(s, "a.b.c.d", 3);
|
||||
|
||||
s.Remove("a.b.*.d", 22);
|
||||
s.Count.ShouldBe(2u);
|
||||
RequireMatches(s, "a.b.c.d", 2);
|
||||
|
||||
s.Remove("a.b.>", 33);
|
||||
s.Count.ShouldBe(1u);
|
||||
RequireMatches(s, "a.b.c.d", 1);
|
||||
|
||||
s.Remove("a.b.c.d", 11);
|
||||
s.Count.ShouldBe(0u);
|
||||
RequireMatches(s, "a.b.c.d", 0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistRemoveCleanup
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistRemoveCleanup()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
s.NumLevels().ShouldBe(0);
|
||||
s.Insert("a.b.c.d.e.f", EmptyStruct.Value);
|
||||
s.NumLevels().ShouldBe(6);
|
||||
s.Remove("a.b.c.d.e.f", EmptyStruct.Value);
|
||||
s.NumLevels().ShouldBe(0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistRemoveCleanupWildcards
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistRemoveCleanupWildcards()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
s.NumLevels().ShouldBe(0);
|
||||
s.Insert("a.b.*.d.e.>", EmptyStruct.Value);
|
||||
s.NumLevels().ShouldBe(6);
|
||||
s.Remove("a.b.*.d.e.>", EmptyStruct.Value);
|
||||
s.NumLevels().ShouldBe(0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistInvalidSubjectsInsert
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistInvalidSubjectsInsert()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
|
||||
// Insert, or subscriptions, can have wildcards, but not empty tokens,
|
||||
// and can not have a FWC that is not the terminal token.
|
||||
Should.Throw<ArgumentException>(() => s.Insert(".foo", EmptyStruct.Value));
|
||||
Should.Throw<ArgumentException>(() => s.Insert("foo.", EmptyStruct.Value));
|
||||
Should.Throw<ArgumentException>(() => s.Insert("foo..bar", EmptyStruct.Value));
|
||||
Should.Throw<ArgumentException>(() => s.Insert("foo.bar..baz", EmptyStruct.Value));
|
||||
Should.Throw<ArgumentException>(() => s.Insert("foo.>.baz", EmptyStruct.Value));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistBadSubjectOnRemove
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistBadSubjectOnRemove()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
Should.Throw<ArgumentException>(() => s.Insert("a.b..d", EmptyStruct.Value));
|
||||
Should.Throw<ArgumentException>(() => s.Remove("a.b..d", EmptyStruct.Value));
|
||||
Should.Throw<ArgumentException>(() => s.Remove("a.>.b", EmptyStruct.Value));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistTwoTokenPubMatchSingleTokenSub
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistTwoTokenPubMatchSingleTokenSub()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
s.Insert("foo", EmptyStruct.Value);
|
||||
RequireMatches(s, "foo", 1);
|
||||
RequireMatches(s, "foo.bar", 0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistInsertWithWildcardsAsLiterals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistInsertWithWildcardsAsLiterals()
|
||||
{
|
||||
var s = GenericSublist<int>.NewSublist();
|
||||
var subjects = new[] { "foo.*-", "foo.>-" };
|
||||
for (var i = 0; i < subjects.Length; i++)
|
||||
{
|
||||
var subject = subjects[i];
|
||||
s.Insert(subject, i);
|
||||
RequireMatches(s, "foo.bar", 0);
|
||||
RequireMatches(s, subject, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistRemoveWithWildcardsAsLiterals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistRemoveWithWildcardsAsLiterals()
|
||||
{
|
||||
var s = GenericSublist<int>.NewSublist();
|
||||
var subjects = new[] { "foo.*-", "foo.>-" };
|
||||
for (var i = 0; i < subjects.Length; i++)
|
||||
{
|
||||
var subject = subjects[i];
|
||||
s.Insert(subject, i);
|
||||
RequireMatches(s, "foo.bar", 0);
|
||||
RequireMatches(s, subject, 1);
|
||||
Should.Throw<KeyNotFoundException>(() => s.Remove("foo.bar", i));
|
||||
s.Count.ShouldBe(1u);
|
||||
s.Remove(subject, i);
|
||||
s.Count.ShouldBe(0u);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistMatchWithEmptyTokens
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistMatchWithEmptyTokens()
|
||||
{
|
||||
var s = GenericSublist<EmptyStruct>.NewSublist();
|
||||
s.Insert(">", EmptyStruct.Value);
|
||||
|
||||
var subjects = new[]
|
||||
{
|
||||
".foo", "..foo", "foo..", "foo.", "foo..bar", "foo...bar"
|
||||
};
|
||||
|
||||
foreach (var subject in subjects)
|
||||
{
|
||||
RequireMatches(s, subject, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistHasInterest
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistHasInterest()
|
||||
{
|
||||
var s = GenericSublist<int>.NewSublist();
|
||||
s.Insert("foo", 11);
|
||||
|
||||
// Expect to find that "foo" matches but "bar" doesn't.
|
||||
s.HasInterest("foo").ShouldBeTrue();
|
||||
s.HasInterest("bar").ShouldBeFalse();
|
||||
|
||||
// Call Match on a subject we know there is no match.
|
||||
RequireMatches(s, "bar", 0);
|
||||
s.HasInterest("bar").ShouldBeFalse();
|
||||
|
||||
// Remove fooSub and check interest again.
|
||||
s.Remove("foo", 11);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
|
||||
// Try with some wildcards.
|
||||
s.Insert("foo.*", 22);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar").ShouldBeTrue();
|
||||
s.HasInterest("foo.bar.baz").ShouldBeFalse();
|
||||
|
||||
// Remove sub, there should be no interest.
|
||||
s.Remove("foo.*", 22);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar.baz").ShouldBeFalse();
|
||||
|
||||
s.Insert("foo.>", 33);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar").ShouldBeTrue();
|
||||
s.HasInterest("foo.bar.baz").ShouldBeTrue();
|
||||
|
||||
s.Remove("foo.>", 33);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar.baz").ShouldBeFalse();
|
||||
|
||||
s.Insert("*.>", 44);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar").ShouldBeTrue();
|
||||
s.HasInterest("foo.baz").ShouldBeTrue();
|
||||
s.Remove("*.>", 44);
|
||||
|
||||
s.Insert("*.bar", 55);
|
||||
s.HasInterest("foo").ShouldBeFalse();
|
||||
s.HasInterest("foo.bar").ShouldBeTrue();
|
||||
s.HasInterest("foo.baz").ShouldBeFalse();
|
||||
s.Remove("*.bar", 55);
|
||||
|
||||
s.Insert("*", 66);
|
||||
s.HasInterest("foo").ShouldBeTrue();
|
||||
s.HasInterest("foo.bar").ShouldBeFalse();
|
||||
s.Remove("*", 66);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistHasInterestOverlapping
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistHasInterestOverlapping()
|
||||
{
|
||||
var s = GenericSublist<int>.NewSublist();
|
||||
s.Insert("stream.A.child", 11);
|
||||
s.Insert("stream.*", 11);
|
||||
s.HasInterest("stream.A.child").ShouldBeTrue();
|
||||
s.HasInterest("stream.A").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistHasInterestStartingInRace
|
||||
// Tests that HasInterestStartingIn is safe to call concurrently with
|
||||
// modifications to the sublist. Mirrors Go's goroutine test using Tasks.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task TestGenericSublistHasInterestStartingInRace()
|
||||
{
|
||||
var s = GenericSublist<int>.NewSublist();
|
||||
|
||||
// Pre-populate with some patterns.
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
s.Insert("foo.bar.baz", i);
|
||||
s.Insert("foo.*.baz", i + 10);
|
||||
s.Insert("foo.>", i + 20);
|
||||
}
|
||||
|
||||
const int iterations = 1000;
|
||||
|
||||
// Task 1: repeatedly call HasInterestStartingIn.
|
||||
var task1 = Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
s.HasInterestStartingIn("foo");
|
||||
s.HasInterestStartingIn("foo.bar");
|
||||
s.HasInterestStartingIn("foo.bar.baz");
|
||||
s.HasInterestStartingIn("other.subject");
|
||||
}
|
||||
});
|
||||
|
||||
// Task 2: repeatedly modify the sublist.
|
||||
var task2 = Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
var val = 1000 + i;
|
||||
var dynSubject = "test.subject." + (char)('a' + i % 26);
|
||||
s.Insert(dynSubject, val);
|
||||
s.Insert("foo.*.test", val);
|
||||
// Remove may fail if not found (concurrent), so swallow KeyNotFoundException.
|
||||
try { s.Remove(dynSubject, val); } catch (KeyNotFoundException) { }
|
||||
try { s.Remove("foo.*.test", val); } catch (KeyNotFoundException) { }
|
||||
}
|
||||
});
|
||||
|
||||
// Task 3: also call HasInterest (which does lock).
|
||||
var task3 = Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
s.HasInterest("foo.bar.baz");
|
||||
s.HasInterest("foo.something.baz");
|
||||
}
|
||||
});
|
||||
|
||||
await Task.WhenAll(task1, task2, task3);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// TestGenericSublistNumInterest
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TestGenericSublistNumInterest()
|
||||
{
|
||||
var s = GenericSublist<int>.NewSublist();
|
||||
s.Insert("foo", 11);
|
||||
|
||||
void RequireNumInterest(string subj, int expected)
|
||||
{
|
||||
RequireMatches(s, subj, expected);
|
||||
s.NumInterest(subj).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// Expect to find that "foo" matches but "bar" doesn't.
|
||||
RequireNumInterest("foo", 1);
|
||||
RequireNumInterest("bar", 0);
|
||||
|
||||
// Remove fooSub and check interest again.
|
||||
s.Remove("foo", 11);
|
||||
RequireNumInterest("foo", 0);
|
||||
|
||||
// Try with some wildcards.
|
||||
s.Insert("foo.*", 22);
|
||||
RequireNumInterest("foo", 0);
|
||||
RequireNumInterest("foo.bar", 1);
|
||||
RequireNumInterest("foo.bar.baz", 0);
|
||||
|
||||
// Remove sub, there should be no interest.
|
||||
s.Remove("foo.*", 22);
|
||||
RequireNumInterest("foo", 0);
|
||||
RequireNumInterest("foo.bar", 0);
|
||||
RequireNumInterest("foo.bar.baz", 0);
|
||||
|
||||
s.Insert("foo.>", 33);
|
||||
RequireNumInterest("foo", 0);
|
||||
RequireNumInterest("foo.bar", 1);
|
||||
RequireNumInterest("foo.bar.baz", 1);
|
||||
|
||||
s.Remove("foo.>", 33);
|
||||
RequireNumInterest("foo", 0);
|
||||
RequireNumInterest("foo.bar", 0);
|
||||
RequireNumInterest("foo.bar.baz", 0);
|
||||
|
||||
s.Insert("*.>", 44);
|
||||
RequireNumInterest("foo", 0);
|
||||
RequireNumInterest("foo.bar", 1);
|
||||
RequireNumInterest("foo.bar.baz", 1);
|
||||
s.Remove("*.>", 44);
|
||||
|
||||
s.Insert("*.bar", 55);
|
||||
RequireNumInterest("foo", 0);
|
||||
RequireNumInterest("foo.bar", 1);
|
||||
RequireNumInterest("foo.bar.baz", 0);
|
||||
s.Remove("*.bar", 55);
|
||||
|
||||
s.Insert("*", 66);
|
||||
RequireNumInterest("foo", 1);
|
||||
RequireNumInterest("foo.bar", 0);
|
||||
s.Remove("*", 66);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Internal.DataStructures;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HashWheel"/>, mirroring thw_test.go (functional tests only;
|
||||
/// benchmarks are omitted as they require BenchmarkDotNet).
|
||||
/// </summary>
|
||||
public sealed class HashWheelTests
|
||||
{
|
||||
private static readonly long Second = 1_000_000_000L; // nanoseconds
|
||||
|
||||
[Fact]
|
||||
public void HashWheelBasics_ShouldSucceed()
|
||||
{
|
||||
// Mirror: TestHashWheelBasics
|
||||
var hw = HashWheel.NewHashWheel();
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
|
||||
var seq = 1UL;
|
||||
var expires = now + 5 * Second;
|
||||
|
||||
hw.Add(seq, expires);
|
||||
hw.Count.ShouldBe(1UL);
|
||||
|
||||
// Remove non-existent sequence.
|
||||
Should.Throw<InvalidOperationException>(() => hw.Remove(999, expires));
|
||||
hw.Count.ShouldBe(1UL);
|
||||
|
||||
// Remove properly.
|
||||
hw.Remove(seq, expires);
|
||||
hw.Count.ShouldBe(0UL);
|
||||
|
||||
// Already gone.
|
||||
Should.Throw<InvalidOperationException>(() => hw.Remove(seq, expires));
|
||||
hw.Count.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashWheelUpdate_ShouldSucceed()
|
||||
{
|
||||
// Mirror: TestHashWheelUpdate
|
||||
var hw = HashWheel.NewHashWheel();
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
|
||||
var oldExpires = now + 5 * Second;
|
||||
var newExpires = now + 10 * Second;
|
||||
|
||||
hw.Add(1, oldExpires);
|
||||
hw.Count.ShouldBe(1UL);
|
||||
|
||||
hw.Update(1, oldExpires, newExpires);
|
||||
hw.Count.ShouldBe(1UL);
|
||||
|
||||
// Old position gone.
|
||||
Should.Throw<InvalidOperationException>(() => hw.Remove(1, oldExpires));
|
||||
hw.Count.ShouldBe(1UL);
|
||||
|
||||
// New position exists.
|
||||
hw.Remove(1, newExpires);
|
||||
hw.Count.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashWheelExpiration_ShouldExpireOnly_AlreadyExpired()
|
||||
{
|
||||
// Mirror: TestHashWheelExpiration
|
||||
var hw = HashWheel.NewHashWheel();
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
|
||||
|
||||
var seqs = new Dictionary<ulong, long>
|
||||
{
|
||||
[1] = now - 1 * Second, // already expired
|
||||
[2] = now + 1 * Second,
|
||||
[3] = now + 10 * Second,
|
||||
[4] = now + 60 * Second,
|
||||
};
|
||||
foreach (var (s, exp) in seqs)
|
||||
hw.Add(s, exp);
|
||||
hw.Count.ShouldBe((ulong)seqs.Count);
|
||||
|
||||
var expired = new HashSet<ulong>();
|
||||
hw.ExpireTasksInternal(now, (s, _) => { expired.Add(s); return true; });
|
||||
|
||||
expired.Count.ShouldBe(1);
|
||||
expired.ShouldContain(1UL);
|
||||
hw.Count.ShouldBe(3UL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashWheelManualExpiration_ShouldRespectCallbackReturn()
|
||||
{
|
||||
// Mirror: TestHashWheelManualExpiration
|
||||
var hw = HashWheel.NewHashWheel();
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
|
||||
|
||||
for (var s = 1UL; s <= 4; s++)
|
||||
hw.Add(s, now);
|
||||
hw.Count.ShouldBe(4UL);
|
||||
|
||||
// Iterate without removing.
|
||||
var expired = new Dictionary<ulong, ulong>();
|
||||
for (var i = 0UL; i <= 1; i++)
|
||||
{
|
||||
hw.ExpireTasksInternal(now, (s, _) => { expired.TryAdd(s, 0); expired[s]++; return false; });
|
||||
|
||||
expired.Count.ShouldBe(4);
|
||||
expired[1].ShouldBe(1 + i);
|
||||
expired[2].ShouldBe(1 + i);
|
||||
expired[3].ShouldBe(1 + i);
|
||||
expired[4].ShouldBe(1 + i);
|
||||
hw.Count.ShouldBe(4UL);
|
||||
}
|
||||
|
||||
// Remove only even sequences.
|
||||
for (var i = 0UL; i <= 1; i++)
|
||||
{
|
||||
hw.ExpireTasksInternal(now, (s, _) => { expired.TryAdd(s, 0); expired[s]++; return s % 2 == 0; });
|
||||
|
||||
expired[1].ShouldBe(3 + i);
|
||||
expired[2].ShouldBe(3UL);
|
||||
expired[3].ShouldBe(3 + i);
|
||||
expired[4].ShouldBe(3UL);
|
||||
hw.Count.ShouldBe(2UL);
|
||||
}
|
||||
|
||||
// Manually remove remaining.
|
||||
hw.Remove(1, now);
|
||||
hw.Remove(3, now);
|
||||
hw.Count.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashWheelExpirationLargerThanWheel_ShouldExpireAll()
|
||||
{
|
||||
// Mirror: TestHashWheelExpirationLargerThanWheel
|
||||
const int WheelMask = (1 << 12) - 1;
|
||||
var hw = HashWheel.NewHashWheel();
|
||||
|
||||
hw.Add(1, 0);
|
||||
hw.Add(2, Second);
|
||||
hw.Count.ShouldBe(2UL);
|
||||
|
||||
// Timestamp large enough to wrap the entire wheel.
|
||||
var nowWrapped = Second * WheelMask;
|
||||
|
||||
var expired = new HashSet<ulong>();
|
||||
hw.ExpireTasksInternal(nowWrapped, (s, _) => { expired.Add(s); return true; });
|
||||
|
||||
expired.Count.ShouldBe(2);
|
||||
hw.Count.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashWheelNextExpiration_ShouldReturnEarliest()
|
||||
{
|
||||
// Mirror: TestHashWheelNextExpiration
|
||||
var hw = HashWheel.NewHashWheel();
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
|
||||
|
||||
var seqs = new Dictionary<ulong, long>
|
||||
{
|
||||
[1] = now + 5 * Second,
|
||||
[2] = now + 3 * Second, // earliest
|
||||
[3] = now + 10 * Second,
|
||||
};
|
||||
foreach (var (s, exp) in seqs)
|
||||
hw.Add(s, exp);
|
||||
|
||||
var tick = now + 6 * Second;
|
||||
hw.GetNextExpiration(tick).ShouldBe(seqs[2]);
|
||||
|
||||
var empty = HashWheel.NewHashWheel();
|
||||
empty.GetNextExpiration(now + Second).ShouldBe(long.MaxValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashWheelStress_ShouldHandleLargeScale()
|
||||
{
|
||||
// Mirror: TestHashWheelStress
|
||||
var hw = HashWheel.NewHashWheel();
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
|
||||
const int numSeqs = 100_000;
|
||||
|
||||
for (var seq = 0; seq < numSeqs; seq++)
|
||||
{
|
||||
var exp = now + (long)seq * Second;
|
||||
hw.Add((ulong)seq, exp);
|
||||
}
|
||||
|
||||
// Update even sequences.
|
||||
for (var seq = 0; seq < numSeqs; seq += 2)
|
||||
{
|
||||
var oldExp = now + (long)seq * Second;
|
||||
var newExp = now + (long)(seq + numSeqs) * Second;
|
||||
hw.Update((ulong)seq, oldExp, newExp);
|
||||
}
|
||||
|
||||
// Remove odd sequences.
|
||||
for (var seq = 1; seq < numSeqs; seq += 2)
|
||||
{
|
||||
var exp = now + (long)seq * Second;
|
||||
hw.Remove((ulong)seq, exp);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashWheelEncodeDecode_ShouldRoundTrip()
|
||||
{
|
||||
// Mirror: TestHashWheelEncodeDecode
|
||||
var hw = HashWheel.NewHashWheel();
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
|
||||
const int numSeqs = 100_000;
|
||||
|
||||
for (var seq = 0; seq < numSeqs; seq++)
|
||||
{
|
||||
var exp = now + (long)seq * Second;
|
||||
hw.Add((ulong)seq, exp);
|
||||
}
|
||||
|
||||
var b = hw.Encode(12345);
|
||||
b.Length.ShouldBeGreaterThan(17);
|
||||
|
||||
var nhw = HashWheel.NewHashWheel();
|
||||
var stamp = nhw.Decode(b);
|
||||
stamp.ShouldBe(12345UL);
|
||||
|
||||
// Lowest expiry should match.
|
||||
hw.GetNextExpiration(long.MaxValue).ShouldBe(nhw.GetNextExpiration(long.MaxValue));
|
||||
|
||||
// Verify all entries transferred by removing them from nhw.
|
||||
for (var seq = 0; seq < numSeqs; seq++)
|
||||
{
|
||||
var exp = now + (long)seq * Second;
|
||||
nhw.Remove((ulong)seq, exp); // throws if missing
|
||||
}
|
||||
nhw.Count.ShouldBe(0UL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ProcessStatsProvider"/>, mirroring pse_test.go.
|
||||
/// The Go tests compare against `ps` command output — the .NET tests verify
|
||||
/// that values are within reasonable bounds since Process gives us the same data
|
||||
/// through a managed API without needing external command comparison.
|
||||
/// </summary>
|
||||
public sealed class ProcessStatsProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PSEmulationCPU_ShouldReturnReasonableValue()
|
||||
{
|
||||
// Mirror: TestPSEmulationCPU
|
||||
// Allow one sampling cycle to complete.
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
|
||||
ProcessStatsProvider.ProcUsage(out var pcpu, out _, out _);
|
||||
|
||||
// CPU % should be non-negative and at most 100% × processor count.
|
||||
pcpu.ShouldBeGreaterThanOrEqualTo(0);
|
||||
pcpu.ShouldBeLessThanOrEqualTo(100.0 * Environment.ProcessorCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PSEmulationMem_ShouldReturnReasonableValue()
|
||||
{
|
||||
// Mirror: TestPSEmulationMem
|
||||
ProcessStatsProvider.ProcUsage(out _, out var rss, out var vss);
|
||||
|
||||
// RSS should be at least 1 MB (any .NET process uses far more).
|
||||
rss.ShouldBeGreaterThan(1024L * 1024L);
|
||||
|
||||
// VSS should be at least as large as RSS.
|
||||
vss.ShouldBeGreaterThanOrEqualTo(rss);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PSEmulationWin_ShouldCacheAndRefresh()
|
||||
{
|
||||
// Mirror: TestPSEmulationWin (caching behaviour validation)
|
||||
ProcessStatsProvider.ProcUsage(out _, out var rss1, out _);
|
||||
ProcessStatsProvider.ProcUsage(out _, out var rss2, out _);
|
||||
|
||||
// Two immediate calls should return the same cached value.
|
||||
rss1.ShouldBe(rss2);
|
||||
|
||||
// After a sampling interval, values should still be valid.
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
ProcessStatsProvider.ProcUsage(out _, out var rssAfter, out _);
|
||||
rssAfter.ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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";
|
||||
}
|
||||
}
|
||||
BIN
porting.db
BIN
porting.db
Binary file not shown.
@@ -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
39
reports/report_0a54d34.md
Normal 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%)**
|
||||
Reference in New Issue
Block a user