98 Commits

Author SHA1 Message Date
Joseph Doherty
b94a67be6e Implement deferred core utility parity APIs/tests and refresh tracking artifacts 2026-02-27 10:27:05 -05:00
Joseph Doherty
c0aaae9236 chore: verify 43 already-implemented features across scheduler, monitor sort, errors, sdm, ring modules 2026-02-27 10:04:33 -05:00
Joseph Doherty
4e96fb2ba8 Update report artifacts after main merge 2026-02-27 09:59:28 -05:00
Joseph Doherty
ae0a553ab8 Merge branch 'codex/deferred-waitqueue-disk-noop' 2026-02-27 09:58:49 -05:00
Joseph Doherty
a660e38575 Implement deferred WaitQueue, DiskAvailability, and NoOpCache behavior with tests 2026-02-27 09:58:37 -05:00
Joseph Doherty
8849265780 Eliminate PortTracker stub backlog by implementing Raft/file-store/stream/server/client/OCSP stubs and adding coverage. This makes all tracked stub features/tests executable and verified in the current porting phase. 2026-02-27 08:56:26 -05:00
Joseph Doherty
ba4f41cf71 feat: implement SubscriptionIndex + JetStreamMemStore cluster — 39 features verified
Add SubscriptionIndex factory methods, notification wrappers, and
ValidateMapping. Implement 24 MemStore methods (TTL, scheduling, SDM,
age-check, purge/compact/reset) with JetStream header helpers and
constants. Verified features: 987 → 1026.
2026-02-27 06:19:47 -05:00
Joseph Doherty
4e61314c1c docs: add AGENTS.md for OpenAI Codex with PortTracker reference and porting workflow 2026-02-27 06:11:11 -05:00
Joseph Doherty
db1de2a384 chore: update porting reports 2026-02-27 05:51:26 -05:00
Joseph Doherty
7a338dd510 feat: add audit-verified status updates with override tracking
Status updates (feature/test update and batch-update) now verify the
requested status against Roslyn audit classification. Mismatches
require --override "reason" to force. Overrides are logged to a new
status_overrides table and reviewable via 'override list' command.
2026-02-27 05:50:15 -05:00
Joseph Doherty
3297334261 docs: add audit-verified status updates design 2026-02-27 05:46:12 -05:00
Joseph Doherty
4972f998b7 feat: run unit test audit — classify 2680 deferred tests
Reset 2680 deferred unit tests to unknown and ran audit against
dotnet/tests/. Results: 18 reclassified as stub (JetStreamErrors
and SignalHandler test stubs), 2662 remain deferred.
2026-02-27 05:36:34 -05:00
Joseph Doherty
7518b97b79 feat: extend audit command to support unit tests with --type flag 2026-02-27 05:35:51 -05:00
Joseph Doherty
485c7b0c2e docs: add unit test audit extension design 2026-02-27 05:34:31 -05:00
Joseph Doherty
9e2d763741 chore: add unknown status to schema and update porting report 2026-02-27 05:27:47 -05:00
Joseph Doherty
0c9eb2a06c fix: correct 87 dotnet_class name mismatches and re-audit
Fixed 630 features with wrong class names in the DB:
- JetStreamErrors -> JsApiErrors (208)
- RaftNode -> IRaftNode (169)
- JetStreamMemoryStore -> JetStreamMemStore (80)
- 42 *Option -> *ReloadOption mappings
- 13 Sort* -> SortBy* mappings
- 21 other class name corrections

Re-audit reclassified 192 features from deferred:
  verified: 987 (+412 from initial 575)
  stub:     168 (+59 from initial 109)
  deferred: 2500 (-192 from initial 2692)
  n_a:      18 (unchanged)
2026-02-27 05:26:55 -05:00
Joseph Doherty
a91cfbc7bd feat: run feature status audit — classify 3394 unknown features
Automated classification using Roslyn syntax tree analysis:
  verified:  575
  stub:      109
  n_a:       18
  deferred:  2692
2026-02-27 05:21:45 -05:00
Joseph Doherty
26d6d7fe68 feat: add audit command — orchestrates feature status classification 2026-02-27 05:18:28 -05:00
Joseph Doherty
0436e08fc1 feat: add FeatureClassifier — heuristic-based feature classification 2026-02-27 05:17:35 -05:00
Joseph Doherty
2dd23211c7 feat: add SourceIndexer — Roslyn-based .NET source parser for audit 2026-02-27 05:16:58 -05:00
Joseph Doherty
c5c6fbc027 chore: add Roslyn package to PortTracker for feature audit 2026-02-27 05:16:07 -05:00
Joseph Doherty
84dc9d1e1d docs: add feature audit script implementation plan
7 tasks: add Roslyn package, create SourceIndexer, FeatureClassifier,
AuditCommand, smoke test, execute audit, cleanup.
2026-02-27 05:12:49 -05:00
Joseph Doherty
60dce2dc9a docs: add feature audit script design
Automated PortTracker CLI command (feature audit) using Roslyn syntax
tree analysis to classify 3394 unknown features into verified/stub/n_a/deferred.
2026-02-27 05:09:37 -05:00
Joseph Doherty
e7f259710a docs: add feature status audit implementation plan
68 batch tasks processing 50 features each. Each batch: fetch unknown
features, inspect .NET source, classify, dry-run, execute via PortTracker CLI.
2026-02-27 04:50:37 -05:00
Joseph Doherty
810ef29dbb docs: add feature status audit design
Design for classifying 3394 unknown features against their .NET
implementations in batches of 50, using PortTracker batch-update
with mandatory dry-run before execution.
2026-02-27 04:49:21 -05:00
Joseph Doherty
01df4ccff3 chore: update porting reports 2026-02-27 04:45:23 -05:00
Joseph Doherty
4ba6b2642e Merge branch 'worktree-agent-ac3fde22'
# Conflicts:
#	reports/current.md
#	reports/report_0a6e6bf.md
2026-02-27 04:44:05 -05:00
Joseph Doherty
21bb760e63 Merge branch 'worktree-agent-a0a5dc7b'
# Conflicts:
#	reports/current.md
#	reports/report_0a6e6bf.md
2026-02-27 04:43:58 -05:00
Joseph Doherty
4901249511 Merge branch 'worktree-agent-a54fc93d'
# Conflicts:
#	reports/current.md
#	reports/report_0a6e6bf.md
2026-02-27 04:43:51 -05:00
Joseph Doherty
7769966e2e feat(porttracker): add library batch-update and batch-map commands 2026-02-27 04:43:11 -05:00
Joseph Doherty
750916caed feat(porttracker): add module batch-update and batch-map commands 2026-02-27 04:42:49 -05:00
Joseph Doherty
b63f66fbdc feat(porttracker): add test batch-update and batch-map commands 2026-02-27 04:42:30 -05:00
Joseph Doherty
2a900bf56a feat(porttracker): add feature batch-update and batch-map commands 2026-02-27 04:42:17 -05:00
Joseph Doherty
0a6e6bf60d feat(porttracker): add BatchFilters shared infrastructure 2026-02-27 04:40:27 -05:00
Joseph Doherty
3f6c5f243d feat(porttracker): add ExecuteInTransaction to Database 2026-02-27 04:38:59 -05:00
Joseph Doherty
a99092d0bd docs: add PortTracker batch operations implementation plan
7 tasks: Database transaction helper, BatchFilters infrastructure,
batch commands for feature/test/module/library, and smoke tests.
2026-02-27 04:37:36 -05:00
Joseph Doherty
97be7a25a2 docs: add PortTracker batch operations design
Design for batch-update and batch-map subcommands across all entity
types (feature, test, module, library) with shared filter infrastructure
and dry-run-by-default safety.
2026-02-27 04:34:52 -05:00
Joseph Doherty
11ec33da53 fix: mark server module features as deferred, not verified
Add 'deferred' to features.status CHECK constraint (table migration).
Server module (module_id=8) 3394 features: verified → deferred.
These features have ported implementations but their unit tests are
deferred pending a runnable .NET server end-to-end.
Small module features (modules 1-7, 9-12) remain verified.
2026-02-26 21:53:53 -05:00
Joseph Doherty
1c5921d2c1 fix(p7-10): fix integration test quality issues (server guard, parallelism, flakiness, exception propagation) 2026-02-26 20:21:29 -05:00
Joseph Doherty
3e35ffadce chore: remove UnitTest1.cs scaffolding artifact from unit test project 2026-02-26 20:17:25 -05:00
Joseph Doherty
6a1df6b6f8 feat(p7-10): mark deferred tests, add integration tests, close Phase 7
- 2126 server-integration tests marked deferred
- NatsServerBehaviorTests.cs replaces UnitTest1.cs placeholder
- Server module and all features marked verified
- stub tests cleared to deferred
2026-02-26 20:14:38 -05:00
Joseph Doherty
9552f6e7e9 fix(p7-09): move DirectoryStoreTests to Accounts/, add missing PriorityPolicy test case 2026-02-26 20:10:04 -05:00
Joseph Doherty
f0faaffe69 feat(p7-09): JetStream unit tests — versioning (12), dirstore (12), batching/errors deferred (66)
Port session P7-09: add tests from jetstream_versioning_test.go (T:1791–1808),
dirstore_test.go (T:285–296), jetstream_batching_test.go (T:716–744),
jetstream_errors_test.go (T:1381–1384), and accounts_test.go (T:80–110).

- JetStreamVersioningTests: 12 active unit tests + 6 deferred (server-required)
- DirectoryStoreTests: 12 filesystem tests using fake JWTs (no NKeys dependency)
- JetStreamBatchingTests: 29 deferred stubs (all require running JetStream cluster)
- JetStreamErrorsTests: 4 deferred stubs (NewJS* factories not yet ported)
- accounts_test.go T:80–110: 31 deferred (all use RunServerWithConfig)

Fix DirJwtStore.cs expiration bugs:
  - Use DateTimeOffset.UtcNow.UtcTicks (not Unix-relative ticks) for expiry comparison
  - Replace in-place JwtItem mutation with new-object replacement so DrainStale
    can detect stale heap entries via ReferenceEquals check

Add JetStreamVersioning.cs methods: SetStaticStreamMetadata,
SetDynamicStreamMetadata, CopyStreamMetadata, SetStaticConsumerMetadata,
SetDynamicConsumerMetadata, SetDynamicConsumerInfoMetadata, CopyConsumerMetadata.

Tests: 725 pass, 53 skipped/deferred, 0 failures.
DB: +24 complete, +66 deferred.
2026-02-26 20:02:00 -05:00
Joseph Doherty
6e90eea736 feat(p7-07): defer all 249 filestore tests — FileStore implementation is a stub
All methods on JetStreamFileStore throw NotImplementedException (session 18
placeholder). Marked all 249 unit_tests (IDs 351–599) for server/filestore_test.go
as deferred in porting.db. No test file created; tests will be written once the
FileStore implementation is ported. All 701 existing unit tests continue to pass.
2026-02-26 19:40:05 -05:00
Joseph Doherty
0950580967 feat(p7-06): port memstore & store interface tests (38 tests)
Add JetStreamMemoryStoreTests (27 tests, T:2023-2056) and
StorageEngineTests (11 tests, T:2943-2957) covering the JetStream
memory store and IStreamStore interface. Fix 10 bugs in MemStore.cs
discovered during test authoring: FirstSeq constructor, Truncate(0)
SubjectTree reset, PurgeEx subject-filtered implementation,
UpdateConfig MaxMsgsPer enforcement, FilteredStateLocked partial
range scan, StoreRawMsgLocked DiscardNewPer, MultiLastSeqs maxSeq
fallback scan + LastNeedsUpdate recalculation, AllLastSeqs
LastNeedsUpdate recalculation, LoadLastLocked LazySubjectState
recalculation, GetSeqFromTime ts==last equality, and timestamp
precision (100-ns throughout). 20 tests deferred (internal fields,
benchmarks, TTL, filestore-only). All 701 unit tests pass.
2026-02-26 19:35:58 -05:00
Joseph Doherty
917cd33442 feat(p7-05): fill signal & log stubs — SignalHandlerTests, ServerLoggerTests
- Add RemovePassFromTrace, RemoveAuthTokenFromTrace, RemoveSecretsFromTrace
  static methods to ServerLogging (mirrors removeSecretsFromTrace/redact in
  server/client.go); uses same regex patterns as Go source to redact only the
  first match's value with [REDACTED].
- Update ClientConnection.RemoveSecretsFromTrace stub to delegate to
  ServerLogging.RemoveSecretsFromTrace.
- Add 2 unit tests to SignalHandlerTests (T:2919 invalid command, T:2920 invalid
  PID); mark 14 process-injection/subprocess tests as deferred ([Fact(Skip=…)]).
- Create ServerLoggerTests with 3 test methods (T:2020, T:2021, T:2022) covering
  NoPasswordsFromConnectTrace, RemovePassFromTrace (8 theory cases),
  RemoveAuthTokenFromTrace (8 theory cases).
- DB: 3 log tests → complete, 2 signal tests → complete, 14 signal tests → deferred.
- All 663 unit tests pass (was 645), 14 deferred skipped.
2026-02-26 19:15:57 -05:00
Joseph Doherty
364329cc1e feat(p7-04): fill auth & config-check stubs — 1 written, 39 deferred
auth_test.go (6): T:153 GetAuthErrClosedState written as pure unit test;
T:147/149-152 use RunServer/RunServerWithConfig → deferred.
auth_callout_test.go (31): all use NewAuthTest (RunServer) → all deferred.
config_check_test.go (3): depend on Go .conf-format parser not yet ported → deferred.
Adds 7 new test methods to AuthHandlerTests; suite grows 638→645.
2026-02-26 19:07:44 -05:00
Joseph Doherty
91f86b9f51 feat(p7-03): fill jwt_test.go stubs — all 88 marked deferred
All 88 unit test stubs in server/jwt_test.go (IDs 1809–1896) depend on
server infrastructure (RunServer, opTrustBasicSetup, newClientForServer,
s.LookupAccount, s.UpdateAccountClaims, etc.) and cannot be exercised as
pure unit tests. Marked all 88 as 'deferred' for Phase 8 integration testing.
Full suite remains at 638 passing tests.
2026-02-26 19:04:02 -05:00
Joseph Doherty
f0b4138459 feat(p7-02): fill opts_test.go stubs — ServerOptionsTests
Write 3 unit tests portable without a running server:
- ListenMonitoringDefault (T:2524): SetBaselineOptions propagates Host → HttpHost
- GetStorageSize (T:2576): StorageSizeJsonConverter.Parse K/M/G/T suffixes
- ClusterNameAndGatewayNameConflict (T:2571): ValidateOptions returns ErrClusterNameConfigConflict

Mark 74 opts_test.go stubs deferred: tests require either the NATS
conf-format parser (not yet ported), a running server (RunServer/NewServer),
or CLI flag-parsing infrastructure (ConfigureOptions).

Fix StorageSizeJsonConverter.Parse to return 0 for empty input,
matching Go getStorageSize("") == (0, nil).

Total unit tests: 638 passing.
2026-02-26 19:00:18 -05:00
Joseph Doherty
8b63a6f6c2 feat(p7-01): verify 11 small modules (114 tests), mark thw benchmarks n/a 2026-02-26 18:53:54 -05:00
Joseph Doherty
08620388f1 feat(p7-01): add 'deferred' status to unit_tests schema
SQLite table recreated (no ALTER TABLE support for CHECK constraints).
porting-schema.sql updated to match. Row count unchanged at 3257.
2026-02-26 18:50:50 -05:00
Joseph Doherty
7750b46f9f docs: Phase 7 implementation plan — 11 tasks, 10 sessions
Covers schema migration, small-module verification, 224 stub fills,
401 new unit tests, 2126 deferred server-integration tests, and
NatsServerBehaviorTests integration baseline.
2026-02-26 18:49:24 -05:00
Joseph Doherty
d09de1c5cf docs: Phase 7 design — porting verification approach
Defines two-layer test strategy (unit vs server-integration/deferred),
10-session structure, schema extension adding deferred status, and
completion criteria for Phase 7.
2026-02-26 18:38:28 -05:00
Joseph Doherty
a0c9c0094c fix: session B — Go-faithful auth error states, NKey padding, permissions, signal disposal 2026-02-26 17:49:13 -05:00
Joseph Doherty
8c380e7ca6 feat: session B — auth implementation + signals (26 stubs complete)
Implement ConfigureAuthorization, CheckAuthentication, and full auth
dispatch in NatsServer.Auth.cs; add HandleSignals in NatsServer.Signals.cs;
extend AuthHandler with GetAuthErrClosedState, ValidateProxies,
GetTlsAuthDcs, CheckClientTlsCertSubject, ProcessUserPermissionsTemplate;
add ReadOperatorJwt/ValidateTrustedOperators to JwtProcessor; add
AuthCallout stub; add auth accessor helpers to ClientConnection; add
NATS.NKeys package for NKey signature verification; 12 new tests pass.
2026-02-26 17:38:46 -05:00
Joseph Doherty
aa1fb5ac4e fix: session A — NoSystemAccount guard, MaxControlLine default, URL/TLS converter tests 2026-02-26 17:29:05 -05:00
Joseph Doherty
9c1ffc0995 feat: session A — config binding via appsettings.json (67 stubs complete)
Add JSON attributes to ServerOptions, four custom JSON converters
(NatsDurationJsonConverter, TlsVersionJsonConverter, NatsUrlJsonConverter,
StorageSizeJsonConverter), ServerOptionsConfiguration for JSON file/string
binding, and 15 tests covering config parsing, duration parsing, and size
parsing. Mark 67 opts.go features complete in porting.db.
2026-02-26 17:18:28 -05:00
Joseph Doherty
8253f975ec docs: implementation plan for completing 93 stub features 2026-02-26 16:59:33 -05:00
Joseph Doherty
63715f256a docs: design for completing 93 stub features (config binding + auth implementation) 2026-02-26 16:54:05 -05:00
Joseph Doherty
a58e8e2572 feat: port sessions 21-23 — Streams, Consumers, MQTT, WebSocket & OCSP
Session 21 (402 features, IDs 3195-3387, 584-792):
- JetStream/StreamTypes.cs: StreamInfo, ConsumerInfo, SequenceInfo,
  JSPubAckResponse, WaitQueue, ClusterInfo, PeerInfo, message types,
  ConsumerAction enum, CreateConsumerRequest, PriorityGroupState
- JetStream/NatsStream.cs: NatsStream class (stub methods, IDisposable)
- JetStream/NatsConsumer.cs: NatsConsumer class (stub methods, IDisposable)
- Updated JetStreamApiTypes.cs: removed duplicate StreamInfo/ConsumerInfo stubs

Session 22 (153 features, IDs 2252-2404):
- Mqtt/MqttConstants.cs: all MQTT protocol constants, packet types, flags
- Mqtt/MqttTypes.cs: MqttSession, MqttSubscription, MqttWill, MqttJsa,
  MqttAccountSessionManager, MqttHandler and supporting types
- Mqtt/MqttHandler.cs: per-client MQTT state, MqttServerExtensions stubs

Session 23 (97 features, IDs 3506-3543, 2443-2501):
- WebSocket/WebSocketConstants.cs: WsOpCode enum, frame bits, close codes
- WebSocket/WebSocketTypes.cs: WsReadInfo, SrvWebsocket (replaces stub),
  WebSocketHandler stubs
- Auth/Ocsp/OcspTypes.cs: OcspMode, OcspMonitor (replaces stub),
  IOcspResponseCache (replaces stub), NoOpCache, LocalDirCache

All features (3503 complete, 0 not_started). Phase 6 now at 58.9%.
2026-02-26 16:31:42 -05:00
Joseph Doherty
e6bc76b315 feat: port session 20 — JetStream Cluster & Raft types
Port IRaftNode (37-method interface), Raft, RaftState, EntryType, Entry,
AppendEntry, CommittedEntry, VoteRequest/VoteResponse, PeerState from
jetstream_cluster.go; JetStreamCluster, StreamAssignment, ConsumerAssignment,
EntryOp (19 values) and supporting types from jetstream_cluster.go.

Removes IRaftNode stub from NatsServerTypes.cs.
429 features marked complete (IDs 2599-2796, 1520-1750).
2026-02-26 16:23:39 -05:00
Joseph Doherty
84d450b4a0 feat: port session 19 — JetStream Core
- JetStreamTypes: JetStreamConfig, JetStreamStats, JetStreamAccountLimits,
  JetStreamTier, JetStreamAccountStats, JetStream engine, JsAccount, JsaUsage
- JetStreamApiTypes: 50+ JSApi request/response types, API subject constants
- JetStreamErrors: JsApiError + JsApiErrors with all 203 error codes
- JetStreamVersioning: version constants and API level helpers
- JetStreamBatching: Batching, BatchGroup, BatchStagedDiff, BatchApply
- Removed JetStreamConfig/JetStreamState stubs from NatsServerTypes.cs
- 374 features complete (IDs 1368-1519, 1751-1972)
2026-02-26 16:14:40 -05:00
Joseph Doherty
3cffa5b156 feat: port session 18 — JetStream File Store
- FileStoreTypes: FileStoreConfig, FileStreamInfo, FileConsumerInfo, Psi,
  Cache, MsgId, CompressionInfo, ErrBadMsg, FileStoreDefaults constants
- FileStore: JetStreamFileStore implementing IStreamStore (26 methods stubbed)
  with State/Type/Stop/Register* properly implemented
- MessageBlock: MessageBlock type with all 40+ fields, ConsumerFileStore stub
- 312 features complete (IDs 951-1262)
2026-02-26 16:06:50 -05:00
Joseph Doherty
5a2c8a3250 feat: port session 17 — Store Interfaces & Memory Store
- StoreTypes: IStreamStore/IConsumerStore interfaces, StreamConfig/ConsumerConfig,
  all enums (StorageType, RetentionPolicy, DiscardPolicy, AckPolicy, etc.),
  StreamState, SimpleState, LostStreamData, DeleteBlocks/Range/Slice, StoreMsg
- MemStore: JetStreamMemStore with full message CRUD, state tracking, age expiry
- ConsumerMemStore: ConsumerMemStore with delivery/ack state tracking
- DiskAvailability: cross-platform disk space checker
- 135 features complete (IDs 3164-3194, 2068-2165, 827-832)
2026-02-26 16:02:03 -05:00
Joseph Doherty
77403e3d31 feat: port sessions 14-16 — Routes, Leaf Nodes & Gateways
Session 14 (57 features, IDs 2895-2951):
- RouteTypes: RouteType enum, Route, RouteInfo, ConnectInfo, ASubs, GossipMode

Session 15 (71 features, IDs 1979-2049):
- LeafNodeTypes: Leaf, LeafNodeCfg (replaces stub), LeafConnectInfo

Session 16 (91 features, IDs 1263-1353):
- GatewayTypes: GatewayInterestMode, SrvGateway (replaces stub), GatewayCfg,
  Gateway, OutSide, InSide, SitAlly, GwReplyMap, GwReplyMapping
2026-02-26 15:50:51 -05:00
Joseph Doherty
ce45dff994 feat: port sessions 12 & 13 — Events/Monitoring/MsgTrace + Config Reload
Session 12 (218 features, IDs 854-950, 2166-2251, 2405-2439):
- EventTypes: system subjects, event message types, InternalState, ConnectEventMsg,
  DisconnectEventMsg, AccountNumConns, ServerIdentity, DataStats
- MonitorTypes: Connz, ConnInfo, ConnzOptions, ConnState, ProxyInfo, TlsPeerCert
- MonitorSortOptions: SortOpt, ConnInfos, all 13 sort comparers
- MsgTraceTypes: IMsgTrace, MsgTraceBase + 6 concrete types, custom JSON converter

Session 13 (89 features, IDs 2800-2888):
- ReloadOptions: IReloadOption interface, NoopReloadOption base, 50 option classes
  covering logging, TLS, auth, cluster, JetStream, MQTT, OCSP, misc
2026-02-26 15:46:14 -05:00
Joseph Doherty
12a14ec476 feat: port session 11 — Accounts & Directory JWT Store
- Account: full Account class (200 features) with subject mappings,
  connection counting, export/import checks, expiration timers
- DirJwtStore: directory-based JWT storage with sharding and expiry
- AccountResolver: IAccountResolver, MemoryAccountResolver,
  UrlAccountResolver, DirAccountResolver, CacheDirAccountResolver
- AccountTypes: all supporting types (AccountLimits, SConns, ExportMap,
  ImportMap, ServiceExport, StreamExport, ServiceLatency, etc.)
- 34 unit tests (599 total), 234 features complete (IDs 150-349, 793-826)
2026-02-26 15:37:08 -05:00
Joseph Doherty
06779a1f77 feat: port session 10 — Server Core Runtime, Accept Loops & Listeners
Ports server/server.go lines 2577–4782 (~1,881 Go LOC), implementing ~97
features (IDs 3051–3147) across three new partial-class files.

New files:
- NatsServer.Lifecycle.cs: Shutdown, WaitForShutdown, RemoveClient,
  SendLDMToClients, LameDuckMode, LDMClientByID, rate-limit logging,
  DisconnectClientByID, SendAsyncInfoToClients
- NatsServer.Listeners.cs: AcceptLoop, GetServerListener, InProcessConn,
  AcceptConnections, GenerateInfoJson, CopyInfo, CreateClient/Ex/InProcess,
  StartMonitoring (HTTP/HTTPS), AddConnectURLs/RemoveConnectURLs,
  TlsVersion/TlsVersionFromString, GetClientConnectURLs, ResolveHostPorts,
  PortsInfo/PortFile/LogPorts, ReadyForListeners, GetRandomIP, AcceptError
- Internal/WaitGroup.cs: Go-style WaitGroup using TaskCompletionSource

Modified:
- Auth/AuthTypes.cs: Account now implements INatsAccount (stub)
- NatsServerTypes.cs: ServerInfo.ShallowClone(), removed duplicate RefCountedUrlSet
- NatsServer.cs: _info promoted to internal for test access
- Properties/AssemblyInfo.cs: InternalsVisibleTo(DynamicProxyGenAssembly2)
- ServerTests.cs: 20 new session-10 unit tests (GenerateInfoJson, TlsVersion,
  CopyInfo, GetRandomIP — Test IDs 2895, 2906)

All 565 unit tests + 1 integration test pass.
2026-02-26 15:08:23 -05:00
Joseph Doherty
0df93c23b0 feat: port session 09 — Server Core Init & Config
Port server/server.go account management and initialization (~1950 LOC):

- NatsServer.cs: full server struct fields (atomic counters, locks, maps,
  stubs for gateway/websocket/mqtt/ocsp/leafnode)
- NatsServer.Init.cs: factory methods (New/NewServer/NewServerFromConfig),
  compression helpers (ValidateAndNormalizeCompressionOption,
  SelectCompressionMode, SelectS2AutoModeBasedOnRtt, CompressOptsEqual),
  cluster-name management, validation (ValidateCluster, ValidatePinnedCerts,
  ValidateOptions), trusted-key processing, CLI helpers, running-state checks,
  and Start() stub
- NatsServer.Accounts.cs: account management (ConfigureAccounts,
  LookupOrRegisterAccount, RegisterAccount, SetSystemAccount,
  SetDefaultSystemAccount, SetSystemAccountInternal, CreateInternalClient*,
  ShouldTrackSubscriptions, RegisterAccountNoLock, SetAccountSublist,
  SetRouteInfo, LookupAccount, LookupOrFetchAccount, UpdateAccount,
  UpdateAccountWithClaimJwt, FetchRawAccountClaims, FetchAccountClaims,
  VerifyAccountClaims, FetchAccountFromResolver, GlobalAccountOnly,
  StandAloneMode, ConfiguredRoutes, ActivePeers, ComputeRoutePoolIdx)
- NatsServerTypes.cs: ServerInfo, ServerStats, NodeInfo, ServerProtocol,
  CompressionMode constants, AccountClaims stub, InternalState stub, and
  cross-session stubs for JetStream/gateway/websocket/mqtt/ocsp
- AuthTypes.cs: extend Account stub with Issuer, ClaimJwt, RoutePoolIdx,
  Incomplete, Updated, Sublist, Server fields, and IsExpired()
- ServerOptions.cs: add Accounts property (List<Account>)
- ServerTests.cs: 38 standalone tests (IDs 2866, 2882, plus compression
  and validation helpers); server-dependent tests marked n/a

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

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

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

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

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

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

Also adds Phase 6 porting plan and task-tracking JSON.
2026-02-26 08:03:21 -05:00
Joseph Doherty
023a5ddb7e chore: complete phase 5 - verify all .NET mappings
All verification checks passed: zero unmapped items, zero collisions,
all PascalCase naming, valid namespaces, all N/A items justified, all
tests targeting correct project. Fixed 8 non-PascalCase method names.
2026-02-26 07:45:05 -05:00
Joseph Doherty
dbc55d2e7b chore: complete phase 4 - map all Go items to .NET solution design
Map 12 modules, 3673 features, and 3257 tests to .NET classes and
methods. 41 platform-specific features marked N/A. Zero naming
collisions. Design reviewed and refined via Codex MCP (renamed
ambiguous classes, applied .NET naming conventions).
2026-02-26 07:38:30 -05:00
Joseph Doherty
8051436f57 docs: add .NET coding standards and reference from phase docs
Establish project-wide rules for testing (xUnit 3 / Shouldly /
NSubstitute), logging (Microsoft.Extensions.Logging + Serilog +
LogContext), and general C# conventions. Referenced from CLAUDE.md
and phases 4-7.
2026-02-26 07:27:30 -05:00
Joseph Doherty
f7a4f56ce4 fix: update logger library mapping to Microsoft.Extensions.Logging 2026-02-26 07:21:59 -05:00
Joseph Doherty
1fd7e5193b chore: complete phase 3 - map all 36 Go libraries to .NET equivalents
Map every external Go dependency to its .NET BCL, NuGet, or custom
implementation target, completing the library mapping phase.
2026-02-26 07:20:16 -05:00
Joseph Doherty
e0e5e427e1 docs: complete phase 2 - verification of captured items
All database counts verified against disk baselines: 109 source files,
85 test files, 3673 features, 3257 tests, 36 library imports. Zero
orphaned or dangling records. No circular dependencies.
2026-02-26 07:15:16 -05:00
Joseph Doherty
c5964c66c5 chore: complete phase 1 - Go codebase decomposition
Run Go AST analyzer against nats-server source, populating porting.db
with 12 modules, 3673 features, 3257 unit tests, 11 inter-module
dependencies, and 36 external library imports.
2026-02-26 07:06:57 -05:00
Joseph Doherty
6021d8445e docs: link completion criteria to Gitea issues and add issue tracking guidance
- Created 52 Gitea issues across milestones 1-7, one per completion criterion
- Each criterion now links to its corresponding issue ([#N](url) format)
- Milestone Tracking sections updated with Issue Updates subsection:
  when/how to comment, close issues via CLI or web UI
- Phases 4-7 criteria converted from plain bullets to checkbox format
2026-02-26 06:50:08 -05:00
Joseph Doherty
6983cb60bb docs: add Gitea milestone tracking instructions to all phase docs
Each phase doc now includes:
- Milestone Tracking section linking to the corresponding Gitea milestone
- Checklist item in Completion Criteria to close the milestone
- Phase 7 also closes the Final: Porting Complete milestone (ID 8)
2026-02-26 06:44:48 -05:00
Joseph Doherty
8d68f63e6c chore: update project structure, naming, and add reporting infrastructure
- Update all 7 phase docs with source/target location references
  (golang/ for Go source, dotnet/ for .NET version)
- Rename NATS.Server to ZB.MOM.NatsNet.Server in phase 4-7 docs
- Update solution layout to dotnet/src/ and dotnet/tests/ structure
- Create CLAUDE.md with project summary and phase links
- Update .gitignore: track porting.db, add standard .NET patterns
- Add golang/nats-server as git submodule
- Add reports/generate-report.sh and pre-commit hook
- Add documentation_rules.md to version control
2026-02-26 06:38:56 -05:00
Joseph Doherty
ca6ed0f09f docs: add phase 4-7 instruction guides 2026-02-26 06:23:13 -05:00
Joseph Doherty
1bc64cf36e docs: add phase 1-3 instruction guides 2026-02-26 06:22:21 -05:00
Joseph Doherty
cecbb49653 feat(porttracker): add all remaining commands (feature, test, library, dependency, report, phase) 2026-02-26 06:17:43 -05:00
Joseph Doherty
c31bf6050d feat(go-analyzer): add SQLite writer, complete analyzer pipeline
Add sqlite.go with DBWriter that writes analysis results (modules,
features, tests, dependencies, library mappings) to the porting
database. Successfully analyzes nats-server: 12 modules, 3673
features, 3257 tests, 36 library mappings, 11 dependencies.
2026-02-26 06:15:01 -05:00
Joseph Doherty
6f5a063307 feat(porttracker): add module commands (list, show, update, map, set-na) 2026-02-26 06:12:40 -05:00
Joseph Doherty
864749f681 feat(go-analyzer): add file-to-module grouping logic 2026-02-26 06:11:09 -05:00
Joseph Doherty
f0f5d6d6b3 feat(go-analyzer): add AST parsing and analysis engine 2026-02-26 06:11:06 -05:00
Joseph Doherty
9fe6a8ee36 feat(porttracker): add DB access layer and init command
Add Database.cs with SQLite connection management and helper methods
(Execute, ExecuteScalar, Query), Schema.cs for schema initialization,
and replace default Program.cs with System.CommandLine v3 CLI featuring
global --db/--schema options and an init command.
2026-02-26 06:08:27 -05:00
Joseph Doherty
3b43922f5c feat(go-analyzer): add data model types 2026-02-26 06:06:30 -05:00
323 changed files with 76822 additions and 12 deletions

32
.gitignore vendored
View File

@@ -1,12 +1,29 @@
# SQLite database (local state) # SQLite transient files (WAL mode)
porting.db
porting.db-journal porting.db-journal
porting.db-wal porting.db-wal
porting.db-shm porting.db-shm
# .NET build output # .NET build output
tools/NatsNet.PortTracker/bin/ **/bin/
tools/NatsNet.PortTracker/obj/ **/obj/
# .NET user / IDE integration files
*.user
*.suo
*.userosscache
*.sln.docstates
# Visual Studio cache/options directory
.vs/
# NuGet
*.nupkg
*.snupkg
project.lock.json
project.fragment.lock.json
*.nuget.props
*.nuget.targets
packages/
# Go build output # Go build output
tools/go-analyzer/go-analyzer tools/go-analyzer/go-analyzer
@@ -14,3 +31,10 @@ tools/go-analyzer/go-analyzer
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# IDE files
.idea/
.vscode/*.code-workspace
.vscode/settings.json
.vscode/tasks.json
.vscode/launch.json

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "golang/nats-server"]
path = golang/nats-server
url = https://github.com/nats-io/nats-server.git

228
AGENTS.md Normal file
View File

@@ -0,0 +1,228 @@
# AGENTS.md
## Project Summary
This project ports the NATS messaging server from Go to .NET 10 C#. The Go source (~130K LOC) is the reference at `golang/nats-server/`. Porting progress is tracked in an SQLite database (`porting.db`) managed by the PortTracker CLI tool.
## Folder Layout
```
natsnet/
├── golang/nats-server/ # Go source (read-only reference)
├── dotnet/
│ ├── src/ZB.MOM.NatsNet.Server/ # Main server library
│ ├── src/ZB.MOM.NatsNet.Server.Host/ # Host entry point
│ └── tests/
│ ├── ZB.MOM.NatsNet.Server.Tests/ # Unit tests
│ └── ZB.MOM.NatsNet.Server.IntegrationTests/ # Integration tests
├── tools/NatsNet.PortTracker/ # CLI tracking tool
├── docs/standards/dotnet-standards.md # .NET coding standards (MUST follow)
├── docs/plans/phases/ # Phase instruction guides
├── reports/current.md # Latest porting status
├── porting.db # SQLite tracking database
└── porting-schema.sql # Database schema
```
## Build and Test
```bash
# Build the solution
dotnet build dotnet/
# Run all unit tests
dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.Tests/
# Run filtered tests (by namespace/class)
dotnet test --filter "FullyQualifiedName~ZB.MOM.NatsNet.Server.Tests.Protocol" \
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/
# Run integration tests
dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/
# Generate porting report
./reports/generate-report.sh
```
## .NET Coding Standards
**MUST follow all rules in `docs/standards/dotnet-standards.md`.**
Critical rules (non-negotiable):
- .NET 10, C# latest, nullable enabled
- **xUnit 3** + **Shouldly** + **NSubstitute** for testing
- **NEVER use FluentAssertions or Moq** — these are forbidden
- PascalCase for public members, `_camelCase` for private fields
- File-scoped namespaces: `ZB.MOM.NatsNet.Server.[Module]`
- Use `CancellationToken` on all async signatures
- Use `ReadOnlySpan<byte>` on hot paths
- Test naming: `[Method]_[Scenario]_[Expected]`
- Test class naming: `[ClassName]Tests`
- Structured logging with `ILogger<T>` and `LogContext.PushProperty`
## PortTracker CLI
All tracking commands use this base:
```bash
dotnet run --project tools/NatsNet.PortTracker -- <command> --db porting.db
```
### Querying
| Command | Purpose |
|---------|---------|
| `report summary` | Show overall porting progress |
| `dependency ready` | List items ready to port (no unported deps) |
| `dependency blocked` | List items blocked by unported deps |
| `feature list --status <s>` | List features by status |
| `feature list --module <id>` | List features in a module |
| `feature show <id>` | Show feature details (Go source path, .NET target) |
| `test list --status <s>` | List tests by status |
| `test show <id>` | Show test details |
| `module list` | List all modules |
| `module show <id>` | Show module with its features and tests |
### Updating Status
| Command | Purpose |
|---------|---------|
| `feature update <id> --status <s>` | Update one feature |
| `feature batch-update --ids "1-10" --set-status <s> --execute` | Bulk update features |
| `test update <id> --status <s>` | Update one test |
| `test batch-update --ids "1-10" --set-status <s> --execute` | Bulk update tests |
| `module update <id> --status <s>` | Update module status |
### Audit Verification
Status updates are verified against Roslyn audit results. If the audit disagrees with your requested status, add `--override "reason"` to force it.
```bash
feature update 42 --status verified --override "manually verified logic"
```
### Audit Commands
| Command | Purpose |
|---------|---------|
| `audit --type features` | Dry-run audit of features against .NET source |
| `audit --type tests` | Dry-run audit of tests against test project |
| `audit --type features --execute` | Apply audit classifications to DB |
| `audit --type tests --execute` | Apply test audit classifications to DB |
### Valid Statuses
```
not_started → stub → complete → verified
└→ n_a (not applicable)
└→ deferred (blocked, needs server infra)
```
### Batch ID Syntax
`--ids` accepts: ranges `"100-200"`, lists `"1,5,10"`, or mixed `"1-5,10,20-25"`.
All batch commands default to dry-run. Add `--execute` to apply.
## Porting Workflow
### Finding Work
1. Query for features ready to port:
```bash
dotnet run --project tools/NatsNet.PortTracker -- dependency ready --db porting.db
```
2. Or find deferred/stub features in a specific module:
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature list --module <id> --status deferred --db porting.db
```
3. To find tests that need implementing:
```bash
dotnet run --project tools/NatsNet.PortTracker -- test list --status stub --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- test list --status deferred --db porting.db
```
### Implementing a Feature
1. **Claim it** — mark as stub before starting:
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update <id> --status stub --db porting.db
```
2. **Read the Go source** — use `feature show <id>` to get the Go file path and line numbers, then read the Go implementation.
3. **Write idiomatic C#** — translate intent, not lines:
- Use `async`/`await`, not goroutine translations
- Use `Channel<T>` for Go channels
- Use `CancellationToken` for `context.Context`
- Use `ReadOnlySpan<byte>` on hot paths
- Use `Lock` (C# 13) for `sync.Mutex`
- Use `ReaderWriterLockSlim` for `sync.RWMutex`
4. **Ensure it compiles** — run `dotnet build dotnet/`
5. **Mark complete**:
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update <id> --status complete --db porting.db
```
### Implementing a Unit Test
1. **Read the Go test** — use `test show <id>` to get Go source location.
2. **Read the corresponding .NET feature** to understand the API surface.
3. **Write the test** in `dotnet/tests/ZB.MOM.NatsNet.Server.Tests/` using xUnit 3 + Shouldly + NSubstitute.
4. **Run it**:
```bash
dotnet test --filter "FullyQualifiedName~TestClassName" \
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/
```
5. **Mark verified** (if passing):
```bash
dotnet run --project tools/NatsNet.PortTracker -- test update <id> --status verified --db porting.db
```
### After Completing Work
1. Run affected tests to verify nothing broke.
2. Update DB status for all items you changed.
3. Check what's newly unblocked:
```bash
dotnet run --project tools/NatsNet.PortTracker -- dependency ready --db porting.db
```
4. Generate updated report:
```bash
./reports/generate-report.sh
```
## Go to .NET Translation Reference
| Go Pattern | .NET Equivalent |
|------------|-----------------|
| `goroutine` | `Task.Run` or `async`/`await` |
| `chan T` | `Channel<T>` |
| `select` | `Task.WhenAny` |
| `sync.Mutex` | `Lock` (C# 13) |
| `sync.RWMutex` | `ReaderWriterLockSlim` |
| `sync.WaitGroup` | `Task.WhenAll` or `CountdownEvent` |
| `atomic.Int64` | `Interlocked` methods on `long` field |
| `context.Context` | `CancellationToken` |
| `defer` | `try`/`finally` or `using` |
| `error` return | Exceptions or Result pattern |
| `[]byte` | `byte[]`, `ReadOnlySpan<byte>`, `ReadOnlyMemory<byte>` |
| `map[K]V` | `Dictionary<K,V>` or `ConcurrentDictionary<K,V>` |
| `interface{}` | `object` or generics |
| `time.Duration` | `TimeSpan` |
| `weak.Pointer[T]` | `WeakReference<T>` |

66
CLAUDE.md Normal file
View File

@@ -0,0 +1,66 @@
# CLAUDE.md
## Project Summary
This project is porting the NATS server from Go to .NET 10 C#. The Go source (~130K LOC across 109 non-test files, 85 test files) is at `golang/nats-server/`. The porting process is tracked via an SQLite database (`porting.db`) and managed by two tools: a Go AST analyzer and a .NET PortTracker CLI.
## Folder Layout
```
natsnet/
├── golang/nats-server/ # Go source (reference)
├── dotnet/ # .NET ported version
│ ├── src/
│ │ ├── ZB.MOM.NatsNet.Server/ # Main server library
│ │ └── ZB.MOM.NatsNet.Server.Host/ # Host/entry point
│ └── tests/
│ ├── ZB.MOM.NatsNet.Server.Tests/ # Unit tests
│ └── ZB.MOM.NatsNet.Server.IntegrationTests/ # Integration tests
├── tools/
│ ├── go-analyzer/ # Go AST analyzer (Phases 1-2)
│ └── NatsNet.PortTracker/ # .NET CLI tool (all phases)
├── docs/plans/phases/ # Phase instruction guides
├── reports/ # Generated porting reports
├── porting.db # SQLite tracking database
├── porting-schema.sql # Database schema
└── documentation_rules.md # Documentation conventions
```
## Tools
### Go AST Analyzer
```bash
CGO_ENABLED=1 go build -o go-analyzer . && ./go-analyzer --source golang/nats-server --db porting.db --schema porting-schema.sql
```
### .NET PortTracker CLI
```bash
dotnet run --project tools/NatsNet.PortTracker -- <command> --db porting.db
```
## Phase Instructions
- **Phase 1: Go Codebase Decomposition** - `docs/plans/phases/phase-1-decomposition.md`
- **Phase 2: Verification of Captured Items** - `docs/plans/phases/phase-2-verification.md`
- **Phase 3: Library Mapping** - `docs/plans/phases/phase-3-library-mapping.md`
- **Phase 4: .NET Solution Design** - `docs/plans/phases/phase-4-dotnet-design.md`
- **Phase 5: Mapping Verification** - `docs/plans/phases/phase-5-mapping-verification.md`
- **Phase 6: Initial Porting** - `docs/plans/phases/phase-6-porting.md`
- **Phase 7: Porting Verification** - `docs/plans/phases/phase-7-porting-verification.md`
## .NET Standards
All .NET code must follow the rules in [`docs/standards/dotnet-standards.md`](docs/standards/dotnet-standards.md). Key points:
- .NET 10, C# latest
- **Testing**: xUnit 3, Shouldly, NSubstitute — do NOT use FluentAssertions or Moq
- **Logging**: `Microsoft.Extensions.Logging` with Serilog provider; use `LogContext.PushProperty` for contextual enrichment
- **Naming**: PascalCase for all public members; `ZB.MOM.NatsNet.Server.[Module]` namespace hierarchy
## Reports
- `reports/current.md` always has the latest porting status.
- `reports/report_{commit_id}.md` snapshots are generated on each commit via pre-commit hook.
- Run `./reports/generate-report.sh` manually to regenerate.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
{
"planPath": "docs/plans/2026-02-26-complete-stub-features.md",
"tasks": [
{
"id": 7,
"subject": "Session A — Config binding (67 stubs)",
"status": "pending",
"subtasks": [
"A1: Add JsonPropertyName attrs to ServerOptions.cs",
"A2: Create Config/NatsJsonConverters.cs",
"A3: Create Config/ServerOptionsConfiguration.cs",
"A4: Write Config/ServerOptionsConfigurationTests.cs",
"A5: DB update + commit"
]
},
{
"id": 8,
"subject": "Session B — Auth implementation (26 stubs)",
"status": "pending",
"subtasks": [
"B1: Add NATS.NKeys NuGet package",
"B2: Add operator JWT methods to JwtProcessor.cs",
"B3: Add auth helper methods to AuthHandler.cs",
"B4: Create NatsServer.Auth.cs",
"B5: Create Auth/AuthCallout.cs",
"B6: Create NatsServer.Signals.cs",
"B7: Write Auth/AuthImplementationTests.cs",
"B8: DB update + commit"
]
}
],
"lastUpdated": "2026-02-26T00:00:00Z"
}

View File

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

View File

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

View File

@@ -0,0 +1,144 @@
# Phase 7: Porting Verification — Design
**Date:** 2026-02-26
**Scope:** Verify all ported code through targeted testing; mark server-integration tests as `deferred`
---
## Context
After Phase 6 (23 porting sessions + 93 stub completions), the DB state entering Phase 7:
| Item | Count |
|------|-------|
| Features complete | 3,596 / 3,673 (77 n_a) |
| Unit tests complete | 319 |
| Unit tests stub | 224 |
| Unit tests not_started | 2,533 |
| Unit tests n_a | 181 |
| Unit tests total | 3,257 |
635 unit tests currently pass. 166 `NotImplementedException` stubs remain in the server — the .NET server is not yet runnable end-to-end.
---
## Key Design Decision: Two Test Layers
Go test files (`jetstream_test.go`, `monitor_test.go`, etc.) all use `RunBasicJetStreamServer()` / `RunServer()` — they start a real NATS server over TCP, then connect via NATS client. These are server-integration tests regardless of whether they target a single node or a cluster.
| Layer | Tests | Treatment |
|-------|-------|-----------|
| **Unit** | Pure component logic (no server startup) | Port & verify in Phase 7 |
| **Server-integration** | Require running NatsServer + NATS client | Status `deferred` |
---
## Schema Extension
Add `deferred` to the `unit_tests.status` CHECK constraint:
```sql
-- Migration: add 'deferred' to unit_tests status enum
-- Recreate table with updated constraint or use SQLite trigger workaround
```
`deferred` = test blocked on running server or cluster infrastructure. Distinct from `n_a` (not applicable to this port).
---
## Test Classification
### Unit Tests to Port (~631 new tests)
| Go source file | Not-started / Stub | Component |
|---|---|---|
| `opts_test.go` | 77 stubs + remaining | Config parsing / binding |
| `jwt_test.go` | 88 stubs | JWT decode / validate |
| `auth_test.go` | 6 stubs | Auth handler logic |
| `auth_callout_test.go` | 31 stubs | Auth callout types / helpers |
| `signal_test.go` | 16 stubs | Signal handler registration |
| `log_test.go` | 3 stubs | Logger behaviour |
| `config_check_test.go` | 3 stubs | Config validation |
| `memstore_test.go` | 41 not_started | Memory store logic |
| `store_test.go` | 17 not_started | Store interface contract |
| `filestore_test.go` | 249 not_started | File store read/write/purge |
| `jetstream_errors_test.go` | 4 not_started | Error type checks |
| `jetstream_versioning_test.go` | 18 not_started | Version compatibility |
| `jetstream_batching_test.go` | 29 not_started | Batching logic |
| `dirstore_test.go` | 12 not_started | JWT directory store |
| `accounts_test.go` | 31 not_started | Account logic (unit subset) |
| `thw` module | 6 not_started | Time hash wheel |
### Server-Integration Tests → `deferred` (~1,799 tests)
| Go source file | Count | Deferred reason |
|---|---|---|
| `jetstream_test.go` | 320 | Needs running server |
| `jetstream_consumer_test.go` | 161 | Needs running server |
| `monitor_test.go` | 103 | HTTP monitoring endpoints |
| `reload_test.go` | 73 | Live config reload |
| `routes_test.go` | 70 | Multi-server routing |
| `events_test.go` | 52 | Server event bus |
| `server_test.go` | 20 | Server lifecycle |
| `jetstream_cluster_*` (×4) | 456 | Multi-node cluster |
| `mqtt_test.go` + extras | ~162 | MQTT server |
| `websocket_test.go` | 109 | WebSocket server |
| `raft_test.go` | 104 | Raft consensus |
| `leafnode_test.go` + proxy | 120 | Leaf node infrastructure |
| `gateway_test.go` | 88 | Gateway infrastructure |
| `jetstream_super_cluster_test.go` | 47 | Super-cluster |
| `norace_*` tests | ~141 | Race-detector / timing |
| Benchmark tests | ~20 | Performance only |
| Other cluster/misc | ~53 | Cluster infrastructure |
---
## Session Structure (10 sessions)
| Session | Scope | New tests | Source files |
|---------|-------|-----------|---|
| **P7-01** | Schema migration + small module verification | 0 new (114 existing) | ats, avl, certidp, gsl, pse, stree, thw, tpm |
| **P7-02** | Opts & config stubs + remaining opts tests | ~95 | `opts_test.go` |
| **P7-03** | JWT stubs | 88 | `jwt_test.go` |
| **P7-04** | Auth stubs + auth callout stubs | 37 | `auth_test.go`, `auth_callout_test.go`, `config_check_test.go` |
| **P7-05** | Signal + log stubs | 19 | `signal_test.go`, `log_test.go` |
| **P7-06** | Store unit tests — memory + interface | ~58 | `memstore_test.go`, `store_test.go` |
| **P7-07** | File store unit tests (first half) | ~125 | `filestore_test.go` lines 1~4,000 |
| **P7-08** | File store unit tests (second half) | ~124 | `filestore_test.go` remainder |
| **P7-09** | JetStream unit tests — errors, versioning, batching, dirstore, accounts | ~94 | `jetstream_errors_test.go`, `jetstream_versioning_test.go`, `jetstream_batching_test.go`, `dirstore_test.go`, `accounts_test.go` |
| **P7-10** | Mark deferred, integration tests, DB final update, Phase 7 close | — | DB sweep + Gitea milestones 7 & 8 |
**Total new tests written: ~640**
---
## Verification Flow (per session)
1. Write / fill tests → build → run → confirm green
2. Mark tests `complete` in DB (new tests) then `verified`
3. Mark small modules `verified` in DB (P7-01); server module at P7-10
4. `./reports/generate-report.sh` → commit
---
## Integration Tests (P7-10)
Replace the placeholder `UnitTest1.cs` with `NatsServerBehaviorTests.cs`. Tests run against the **Go NATS server** (not the .NET server) to establish a behavioral baseline:
- Basic pub/sub
- Wildcard matching (`foo.*`, `foo.>`)
- Queue groups
- Connect/disconnect lifecycle
- Protocol error handling
---
## Completion Definition
Phase 7 is complete when:
- All non-`n_a`, non-`deferred` tests are `verified`
- `dotnet run --project tools/NatsNet.PortTracker -- phase check 7 --db porting.db` passes
- Gitea issues #45#52 closed
- Gitea milestones 7 and 8 closed
The ~1,799 `deferred` tests remain for a future phase once the .NET server is end-to-end runnable.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"planPath": "docs/plans/2026-02-26-phase7-plan.md",
"tasks": [
{"id": 13, "subject": "Task 1: Schema Migration — Add deferred status", "status": "pending"},
{"id": 14, "subject": "Task 2: P7-01 — Small module verification (114 tests)", "status": "pending", "blockedBy": [13]},
{"id": 15, "subject": "Task 3: P7-02 — Opts stubs (77 tests)", "status": "pending", "blockedBy": [14]},
{"id": 16, "subject": "Task 4: P7-03 — JWT stubs (88 tests)", "status": "pending", "blockedBy": [14]},
{"id": 17, "subject": "Task 5: P7-04 — Auth & config-check stubs (40 tests)", "status": "pending", "blockedBy": [14]},
{"id": 18, "subject": "Task 6: P7-05 — Signal & log stubs (19 tests)", "status": "pending", "blockedBy": [14]},
{"id": 19, "subject": "Task 7: P7-06 — Memory store & store interface tests (58 tests)", "status": "pending", "blockedBy": [14]},
{"id": 20, "subject": "Task 8: P7-07 — File store tests, first half (~125 tests)", "status": "pending", "blockedBy": [14]},
{"id": 21, "subject": "Task 9: P7-08 — File store tests, second half (~124 tests)", "status": "pending", "blockedBy": [20]},
{"id": 22, "subject": "Task 10: P7-09 — JetStream unit tests (94 tests)", "status": "pending", "blockedBy": [14]},
{"id": 23, "subject": "Task 11: P7-10 — Mark deferred, integration tests, close phase", "status": "pending", "blockedBy": [15, 16, 17, 18, 19, 21, 22]}
],
"lastUpdated": "2026-02-26T00:00:00Z"
}

View File

@@ -0,0 +1,185 @@
# Stub Features Implementation Design
**Date:** 2026-02-26
**Scope:** Complete the 93 remaining `stub` features in Phase 6
**Approach:** Two parallel sessions (Config + Auth)
## Overview
After Phase 6's 23 porting sessions, 93 features remain at `stub` status. They fall into two
independent concerns that can be implemented in parallel:
| Group | Go File | Stubs | Go LOC | Concern |
|-------|---------|-------|--------|---------|
| Config | `server/opts.go` | 67 | ~4,876 | Configuration file parsing / binding |
| Auth | `server/auth.go` | 19 | ~1,296 | Authentication dispatch |
| Auth | `server/auth_callout.go` | 3 | ~456 | External auth callout |
| Auth | `server/jwt.go` | 3 | ~137 | Operator JWT validation |
| Signals | `server/signal.go` | 1 | ~46 | OS signal handling |
---
## Session A: Configuration Binding (67 stubs, opts.go)
### Decision
Map all NATS server configuration to **`appsettings.json`** via
`Microsoft.Extensions.Configuration`. The Go `conf` package tokenizer and the 765-line
`processConfigFileLine` dispatch loop are **not ported** — JSON deserialization replaces them.
### New Files
**`Config/ServerOptionsConfiguration.cs`**
```csharp
public static class ServerOptionsConfiguration
{
public static ServerOptions ProcessConfigFile(string path);
public static ServerOptions ProcessConfigString(string json);
public static void BindConfiguration(IConfiguration config, ServerOptions target);
}
```
- `ProcessConfigFile` uses `new ConfigurationBuilder().AddJsonFile(path).Build()`
- `ProcessConfigString` uses `AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json)))`
- `BindConfiguration` calls `config.Bind(target)` with custom converters registered
**`Config/NatsJsonConverters.cs`**
Custom `JsonConverter<T>` for non-trivial types:
| Converter | Input | Output | Mirrors |
|-----------|-------|--------|---------|
| `DurationJsonConverter` | `"2s"`, `"100ms"`, `"1h30m"` | `TimeSpan` | `parseDuration` |
| `TlsVersionJsonConverter` | `"1.2"`, `"TLS12"` | `SslProtocols` | `parseTLSVersion` |
| `NatsUrlJsonConverter` | `"nats://host:port"` | validated `string` | `parseURL` |
| `StorageSizeJsonConverter` | `"1GB"`, `"512mb"` | `long` (bytes) | `getStorageSize` |
### ServerOptions.cs Changes
Add `[JsonPropertyName("...")]` attributes for fields whose JSON key names differ from C# names.
JSON key names follow NATS server conventions (lowercase, underscore-separated):
```json
{
"port": 4222,
"host": "0.0.0.0",
"tls": { "cert_file": "...", "key_file": "...", "ca_file": "..." },
"cluster": { "port": 6222, "name": "my-cluster" },
"gateway": { "port": 7222, "name": "my-gateway" },
"jetstream": { "store_dir": "/data/jetstream", "max_memory": "1GB" },
"leafnodes": { "port": 7422 },
"mqtt": { "port": 1883 },
"websocket": { "port": 8080 },
"accounts": [ { "name": "A", "users": [ { "user": "u1", "password": "p1" } ] } ]
}
```
### DB Outcome
All 67 opts.go stubs → `complete`:
- Feature IDs 25052574, 2580, 2584 (+ `configureSystemAccount` 2509, `setupUsersAndNKeysDuplicateCheckMap` 2515)
- `parse*` functions have no C# equivalent — their logic is subsumed by converters and JSON binding
---
## Session B: Auth Implementation (26 stubs)
### New Files
**`NatsServer.Auth.cs`** — `partial class NatsServer` with:
| Method | Go Equivalent | Notes |
|--------|--------------|-------|
| `ConfigureAuthorization()` | `configureAuthorization` | Builds `_nkeys`/`_users` dicts from `_opts` |
| `BuildNkeysAndUsersFromOptions()` | `buildNkeysAndUsersFromOptions` | Creates typed lookup maps |
| `CheckAuthforWarnings()` | `checkAuthforWarnings` | Validates auth config consistency |
| `AssignGlobalAccountToOrphanUsers()` | `assignGlobalAccountToOrphanUsers` | — |
| `CheckAuthentication(ClientConnection)` | `checkAuthentication` | Entry point |
| `IsClientAuthorized(ClientConnection)` | `isClientAuthorized` | Check user credentials |
| `ProcessClientOrLeafAuthentication(ClientConnection, ServerOptions)` | `processClientOrLeafAuthentication` | Main 554-line auth dispatch |
| `IsRouterAuthorized(ClientConnection)` | `isRouterAuthorized` | Route-specific auth |
| `IsGatewayAuthorized(ClientConnection)` | `isGatewayAuthorized` | Gateway-specific auth |
| `RegisterLeafWithAccount(ClientConnection, string)` | `registerLeafWithAccount` | — |
| `IsLeafNodeAuthorized(ClientConnection)` | `isLeafNodeAuthorized` | Leaf-specific auth |
| `ProcessProxiesTrustedKeys()` | `processProxiesTrustedKeys` | Proxy key setup |
| `ProxyCheck(ClientConnection, ServerOptions)` | `proxyCheck` | Validate proxy headers |
**Auth dispatch flow in `ProcessClientOrLeafAuthentication`:**
```
if callout configured → ProcessClientOrLeafCallout()
else if JWT bearer → JwtProcessor.ValidateAndRegisterUser()
else if NKey → verify NKey signature (NATS.NKeys NuGet)
else if user+password → BCrypt.Net.BCrypt.Verify() (BCrypt.Net-Next NuGet)
else if TLS cert map → CheckClientTlsCertSubject()
else if no-auth mode → allow (if opts.NoAuth)
→ set client account, permissions, labels
```
**`Auth/AuthCallout.cs`** — `partial class NatsServer` with:
- `ProcessClientOrLeafCallout(ClientConnection, ServerOptions)` — publishes to `$SYS.REQ.USER.AUTH`, waits for signed JWT response, validates it
- `FillClientInfo(AuthorizationRequestClaims, ClientConnection)` — populate auth request payload
- `FillConnectOpts(AuthorizationRequestClaims, ClientConnection)` — populate connect opts in payload
**`Auth/JwtProcessor.cs` additions:**
- `ReadOperatorJwt(string path)` — read operator JWT from file, decode `OperatorClaims`
- `ReadOperatorJwtInternal(string jwtString)` — decode from string
- `ValidateTrustedOperators(ServerOptions opts)` — walk operator → account → user signing key chain
**`Auth/AuthHandler.cs` additions:**
- `ProcessUserPermissionsTemplate(UserPermissionLimits, UserClaims, Account)` — expand `{{account}}`, `{{tag.*}}` template variables in JWT user permissions
- `GetTlsAuthDcs(X509DistinguishedName)` — extract DC= components from TLS cert RDN
- `CheckClientTlsCertSubject(ClientConnection, Func<string, bool>)` — TLS cert subject matching
- `ValidateProxies(ServerOptions)` — validate proxy configuration
- `GetAuthErrClosedState(ClientConnection)` — map auth failure to client closed state enum
### New NuGet Packages
| Package | Version | Purpose |
|---------|---------|---------|
| `BCrypt.Net-Next` | ≥4.0 | bcrypt password hashing and comparison |
| `NATS.NKeys` | ≥2.0 | NKey keypair creation, signature verify |
### `NatsServer.Signals.cs`
New partial class file:
```csharp
// Registers OS signal handlers via PosixSignalRegistration (cross-platform).
// SIGHUP → server.Reload()
// SIGTERM → server.Shutdown()
// SIGINT → server.Shutdown()
// Windows fallback: Console.CancelKeyPress → Shutdown()
```
### DB Outcome
All 26 auth/jwt/callout/signal stubs → `complete`:
- Feature IDs 354383, 19731976, 2584, 3156
---
## File Summary
| File | Action |
|------|--------|
| `Config/ServerOptionsConfiguration.cs` | CREATE |
| `Config/NatsJsonConverters.cs` | CREATE |
| `NatsServer.Auth.cs` | CREATE (partial) |
| `NatsServer.Signals.cs` | CREATE (partial) |
| `Auth/AuthCallout.cs` | CREATE (partial) |
| `Auth/JwtProcessor.cs` | MODIFY (add 3 methods) |
| `Auth/AuthHandler.cs` | MODIFY (add 5 methods) |
| `ServerOptions.cs` | MODIFY (add JsonPropertyName attrs) |
| `ZB.MOM.NatsNet.Server.csproj` | MODIFY (add 2 NuGet packages) |
---
## Testing
Unit tests in `ZB.MOM.NatsNet.Server.Tests/`:
- `Config/ServerOptionsConfigurationTests.cs` — round-trip JSON bind tests for each major option group
- `Auth/AuthHandlerTests.cs` additions — bcrypt comparison, NKey verify, TLS cert subject matching
- `Auth/JwtProcessorTests.cs` additions — operator JWT read/validate
No new test IDs needed — these are implementations of already-tracked Phase 6 features.
After implementation, relevant test IDs in Phase 7 will be marked complete.

View File

@@ -0,0 +1,29 @@
# AGENTS.md Design
## Purpose
Create an `AGENTS.md` file at the project root for OpenAI Codex agents working on this codebase. The file provides project context, PortTracker CLI reference, porting workflow guidance, and pointers to .NET coding standards.
## Target
OpenAI Codex — follows Codex's AGENTS.md discovery conventions (root-level, markdown format, under 32KB).
## Structure Decision
**Flat single-file** at project root. The project information is tightly coupled — PortTracker commands are needed regardless of which directory Codex is editing. A single file keeps everything in context for every session.
## Sections
1. **Project Summary** — What the project is, where Go source and .NET code live
2. **Folder Layout** — Directory tree with annotations
3. **Build and Test** — Commands to build, run unit tests, run filtered tests, run integration tests
4. **.NET Coding Standards** — Pointer to `docs/standards/dotnet-standards.md` with critical rules inlined (forbidden packages, naming, testing framework)
5. **PortTracker CLI** — Full command reference: querying, updating, audit verification, valid statuses, batch syntax
6. **Porting Workflow** — Step-by-step: finding work, implementing features, implementing tests, post-completion checklist
7. **Go to .NET Translation Reference** — Quick-reference table for common Go-to-.NET pattern translations
## Size
~3.5KB — well within Codex's 32KB default limit.
<!-- Last verified against codebase: 2026-02-27 -->

View File

@@ -0,0 +1,85 @@
# Audit-Verified Status Updates Design
## Goal
Require audit verification before applying status changes to features or unit tests. When the requested status disagrees with what the Roslyn audit determines, require an explicit override with a comment. Track all overrides in a new table for later review.
## Architecture
Inline audit verification: when `feature update`, `feature batch-update`, `test update`, or `test batch-update` runs, build the `SourceIndexer` on the fly, classify each item, and compare. If the requested status doesn't match the audit, block the update unless `--override "comment"` is provided.
## Override Table Schema
```sql
CREATE TABLE status_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL CHECK (table_name IN ('features', 'unit_tests')),
item_id INTEGER NOT NULL,
audit_status TEXT NOT NULL,
audit_reason TEXT NOT NULL,
requested_status TEXT NOT NULL,
comment TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
Each row records: which table/item, what the audit said, what the user requested, and their justification.
## CLI Interface
### Single update
```bash
# Audit agrees — applied directly
dotnet run -- feature update 123 --status verified --db porting.db
# Audit disagrees — blocked
# Error: "Audit classifies feature 123 as 'stub'. Use --override 'reason' to force."
# Override
dotnet run -- feature update 123 --status verified --override "Manual review confirms complete" --db porting.db
```
### Batch update
```bash
# All items agree — applied
dotnet run -- feature batch-update --module 5 --set-status verified --execute --db porting.db
# Some items disagree — blocked
# "15 items match audit, 3 require override. Use --override 'reason' to force all."
# Override entire batch (one comment covers all mismatches)
dotnet run -- feature batch-update --module 5 --set-status verified --override "Batch approved" --execute --db porting.db
```
Same interface for `test update` and `test batch-update`.
## Verification Flow
1. Build `SourceIndexer` for the appropriate directory (features → `dotnet/src/...`, tests → `dotnet/tests/...`).
2. For each item: query its `dotnet_class`, `dotnet_method`, `go_file`, `go_method` from DB. Run `FeatureClassifier.Classify()`.
3. Compare requested status vs audit status. Collect mismatches.
4. If mismatches and no `--override`: print details and exit with error.
5. If `--override` provided: apply all updates. Insert one `status_overrides` row per mismatched item.
6. Items that agree with audit: apply normally, no override row logged.
Items that cannot be audited (no dotnet_class/dotnet_method) are treated as mismatches requiring override.
## Override Review Command
```bash
dotnet run -- override list --db porting.db
dotnet run -- override list --type features --db porting.db
```
Tabular output: id, table, item_id, audit_status, requested_status, comment, date.
## Changes Required
1. **porting-schema.sql**: Add `status_overrides` table.
2. **FeatureCommands.cs**: Add `--override` option to `update` and `batch-update`. Integrate audit verification before applying.
3. **TestCommands.cs**: Same changes as FeatureCommands.
4. **New `OverrideCommands.cs`**: `override list` command.
5. **Program.cs**: Wire `override` command group.
6. **Shared helper**: Extract audit verification logic (build indexer, classify, compare) into a reusable method since both feature and test commands need it.

View File

@@ -0,0 +1,154 @@
# Feature Audit Script Design
**Date:** 2026-02-27
**Status:** Approved
## Problem
3394 features in module 8 (`server`) are marked `unknown`. The existing plan (`2026-02-27-feature-status-audit-plan.md`) describes a manual 68-batch process of inspecting .NET source and classifying each feature. This design automates that process.
## Solution
A new PortTracker CLI command `feature audit` that uses Roslyn syntax tree analysis to parse .NET source files, build a method index, and classify all unknown features automatically.
## Command Interface
```
dotnet run --project tools/NatsNet.PortTracker -- feature audit \
--source dotnet/src/ZB.MOM.NatsNet.Server/ \
--output reports/audit-results.csv \
--db porting.db \
[--module 8] \
[--execute]
```
| Flag | Default | Description |
|------|---------|-------------|
| `--source` | `dotnet/src/ZB.MOM.NatsNet.Server/` | .NET source directory to parse |
| `--output` | `reports/audit-results.csv` | CSV report output path |
| `--db` | `porting.db` | SQLite database (inherited from root) |
| `--module` | *(all)* | Restrict to a specific module ID |
| `--execute` | `false` | Apply DB updates (default: dry-run) |
## Architecture
### Component 1: Source Indexer (`Audit/SourceIndexer.cs`)
Parses all `.cs` files under the source directory into Roslyn syntax trees and builds a lookup index.
**Process:**
1. Recursively glob `**/*.cs` (skip `obj/`, `bin/`)
2. Parse each file with `CSharpSyntaxTree.ParseText()`
3. Walk syntax trees for `ClassDeclarationSyntax` and `StructDeclarationSyntax`
4. Extract all method, property, and constructor declarations
5. Build dictionary: `Dictionary<(string className, string memberName), List<MethodInfo>>`
**`MethodInfo`:**
- `FilePath` — source file path
- `LineNumber` — starting line
- `BodyLineCount` — lines in method body (excluding braces)
- `IsStub` — body is `throw new NotImplementedException(...)` or empty
- `IsPartial` — body has some logic AND a `NotImplementedException` throw
- `StatementCount` — number of meaningful statements
**Partial class handling:** Same class name across multiple files produces multiple entries in the index. Lookup checks all of them — a feature is matched if the method exists in ANY file for that class.
**Name matching:** Case-insensitive comparison for both class and method names. Handles `dotnet_class` values that contain commas (e.g. `ClosedRingBuffer,ClosedClient`) by splitting and checking each.
### Component 2: Feature Classifier (`Audit/FeatureClassifier.cs`)
Classifies each feature using the source index. Priority order (first match wins):
**1. N/A Lookup Table**
Checked first against `(go_file, go_method)` or `dotnet_class` patterns:
| Pattern | Reason |
|---------|--------|
| Go logging functions (`Noticef`, `Debugf`, `Tracef`, `Warnf`, `Errorf`, `Fatalf`) | .NET uses Microsoft.Extensions.Logging |
| Go signal handling (`HandleSignals`, `processSignal`) | .NET uses IHostApplicationLifetime |
| Go HTTP handler setup (`Statz`, `Varz`, `Connz`, etc.) | .NET uses ASP.NET middleware |
Table is extensible — add entries as new patterns are identified.
**2. Method Not Found** -> `deferred`
- `dotnet_class` not found in source index, OR
- `dotnet_method` not found within the class
**3. Stub Detection** -> `stub`
- Body is solely `throw new NotImplementedException(...)` (expression-bodied or block)
- Body is empty (no statements)
- Body has logic but also contains `NotImplementedException` (partial implementation)
**4. Verified** -> `verified`
- Method exists with 1+ meaningful statements that are not `NotImplementedException` throws
### Component 3: Audit Command (`Commands/AuditCommand.cs`)
Orchestrates the audit:
1. Query `SELECT id, dotnet_class, dotnet_method, go_file, go_method FROM features WHERE status = 'unknown'` (optionally filtered by module)
2. Build source index via `SourceIndexer`
3. Classify each feature via `FeatureClassifier`
4. Write CSV report
5. Print console summary
6. If `--execute`: update DB in a single transaction per status group
### DB Update Strategy
- Group features by `(new_status, notes)` tuple
- One `UPDATE features SET status = @s, notes = @n WHERE id IN (...)` per group
- All groups in a single transaction
- For `n_a` features: set `notes` to the reason from the lookup table
## Output
### CSV Report
```csv
id,dotnet_class,dotnet_method,go_file,go_method,old_status,new_status,reason
150,ServiceRespType,String,server/accounts.go,String,unknown,verified,Method found with 3 statements
151,Account,NewAccount,server/accounts.go,NewAccount,unknown,stub,Body is throw NotImplementedException
```
### Console Summary
```
Feature Status Audit Results
=============================
Source: dotnet/src/ZB.MOM.NatsNet.Server/ (142 files, 4821 methods indexed)
Features audited: 3394
verified: NNNN
stub: NNNN
n_a: NNNN
deferred: NNNN
Dry-run mode. Add --execute to apply changes.
Report: reports/audit-results.csv
```
## Dependencies
New NuGet package required:
```xml
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
```
## Files to Create/Modify
| File | Action |
|------|--------|
| `tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj` | Add Roslyn package reference |
| `tools/NatsNet.PortTracker/Audit/SourceIndexer.cs` | New — Roslyn source parsing and indexing |
| `tools/NatsNet.PortTracker/Audit/FeatureClassifier.cs` | New — classification heuristics |
| `tools/NatsNet.PortTracker/Commands/AuditCommand.cs` | New — CLI command wiring |
| `tools/NatsNet.PortTracker/Program.cs` | Add `AuditCommand.Create()` to root command |
## Non-Goals
- No semantic analysis (full compilation) — syntax trees are sufficient
- No Go source parsing — we only inspect .NET source
- No unit test reclassification — separate effort
- No code changes to the server project — classification only

View File

@@ -0,0 +1,813 @@
# Feature Audit Script Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Add a `feature audit` command to the PortTracker CLI that uses Roslyn syntax tree analysis to automatically classify 3394 unknown features into verified/stub/n_a/deferred.
**Architecture:** Three new files — `SourceIndexer` parses all .cs files and builds a method lookup index, `FeatureClassifier` applies classification heuristics, `AuditCommand` wires the CLI and orchestrates the audit. Direct DB updates via the existing `Database` class.
**Tech Stack:** `Microsoft.CodeAnalysis.CSharp` (Roslyn) for C# parsing, `Microsoft.Data.Sqlite` (existing), `System.CommandLine` (existing)
**Design doc:** `docs/plans/2026-02-27-feature-audit-script-design.md`
---
## Important Rules (Read Before Every Task)
1. All new files go under `tools/NatsNet.PortTracker/`
2. Follow the existing code style — see `FeatureCommands.cs` and `BatchFilters.cs` for patterns
3. Use `System.CommandLine` v3 (preview) APIs — `SetAction`, `parseResult.GetValue()`, etc.
4. The `Database` class methods: `Query()`, `Execute()`, `ExecuteScalar<T>()`, `ExecuteInTransaction()`
5. Run `dotnet build --project tools/NatsNet.PortTracker` after each file creation to verify compilation
---
### Task 0: Add Roslyn NuGet package
**Files:**
- Modify: `tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
**Step 1: Add the package reference**
Add `Microsoft.CodeAnalysis.CSharp` to the csproj:
```xml
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
```
The `<ItemGroup>` should look like:
```xml
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.3" />
<PackageReference Include="System.CommandLine" Version="3.0.0-preview.1.26104.118" />
</ItemGroup>
```
**Step 2: Restore and build**
Run: `dotnet build --project tools/NatsNet.PortTracker`
Expected: Build succeeded. 0 Error(s).
**Step 3: Commit**
```bash
git add tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj
git commit -m "chore: add Roslyn package to PortTracker for feature audit"
```
---
### Task 1: Create SourceIndexer — data model and file parsing
**Files:**
- Create: `tools/NatsNet.PortTracker/Audit/SourceIndexer.cs`
**Step 1: Create the SourceIndexer with MethodInfo record and indexing logic**
Create `tools/NatsNet.PortTracker/Audit/SourceIndexer.cs`:
```csharp
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace NatsNet.PortTracker.Audit;
/// <summary>
/// Parses .cs files using Roslyn syntax trees and builds a lookup index
/// of (className, memberName) -> list of MethodInfo.
/// </summary>
public sealed class SourceIndexer
{
public record MethodInfo(
string FilePath,
int LineNumber,
int BodyLineCount,
bool IsStub,
bool IsPartial,
int StatementCount);
// Key: (className lowercase, memberName lowercase)
private readonly Dictionary<(string, string), List<MethodInfo>> _index = new();
public int FilesIndexed { get; private set; }
public int MethodsIndexed { get; private set; }
/// <summary>
/// Recursively parses all .cs files under <paramref name="sourceDir"/>
/// (skipping obj/ and bin/) and populates the index.
/// </summary>
public void IndexDirectory(string sourceDir)
{
var files = Directory.EnumerateFiles(sourceDir, "*.cs", SearchOption.AllDirectories)
.Where(f =>
{
var rel = Path.GetRelativePath(sourceDir, f);
return !rel.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
&& !rel.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")
&& !rel.StartsWith($"obj{Path.DirectorySeparatorChar}")
&& !rel.StartsWith($"bin{Path.DirectorySeparatorChar}");
});
foreach (var file in files)
{
IndexFile(file);
FilesIndexed++;
}
}
/// <summary>
/// Looks up all method declarations for a given class and member name.
/// Case-insensitive. Returns empty list if not found.
/// </summary>
public List<MethodInfo> Lookup(string className, string memberName)
{
var key = (className.ToLowerInvariant(), memberName.ToLowerInvariant());
return _index.TryGetValue(key, out var list) ? list : [];
}
/// <summary>
/// Returns true if the class exists anywhere in the index (any member).
/// </summary>
public bool HasClass(string className)
{
var lower = className.ToLowerInvariant();
return _index.Keys.Any(k => k.Item1 == lower);
}
private void IndexFile(string filePath)
{
var source = File.ReadAllText(filePath);
var tree = CSharpSyntaxTree.ParseText(source, path: filePath);
var root = tree.GetCompilationUnitRoot();
foreach (var typeDecl in root.DescendantNodes().OfType<TypeDeclarationSyntax>())
{
var className = typeDecl.Identifier.Text.ToLowerInvariant();
// Methods
foreach (var method in typeDecl.Members.OfType<MethodDeclarationSyntax>())
{
var info = AnalyzeMethod(filePath, method.Body, method.ExpressionBody, method.GetLocation());
AddToIndex(className, method.Identifier.Text.ToLowerInvariant(), info);
}
// Properties (get/set are like methods)
foreach (var prop in typeDecl.Members.OfType<PropertyDeclarationSyntax>())
{
var info = AnalyzeProperty(filePath, prop);
AddToIndex(className, prop.Identifier.Text.ToLowerInvariant(), info);
}
// Constructors — index as class name
foreach (var ctor in typeDecl.Members.OfType<ConstructorDeclarationSyntax>())
{
var info = AnalyzeMethod(filePath, ctor.Body, ctor.ExpressionBody, ctor.GetLocation());
AddToIndex(className, ctor.Identifier.Text.ToLowerInvariant(), info);
}
}
}
private MethodInfo AnalyzeMethod(string filePath, BlockSyntax? body, ArrowExpressionClauseSyntax? expressionBody, Location location)
{
var lineSpan = location.GetLineSpan();
var lineNumber = lineSpan.StartLinePosition.Line + 1;
if (expressionBody is not null)
{
// Expression-bodied: => expr;
var isStub = IsNotImplementedExpression(expressionBody.Expression);
return new MethodInfo(filePath, lineNumber, 1, IsStub: isStub, IsPartial: false, StatementCount: isStub ? 0 : 1);
}
if (body is null || body.Statements.Count == 0)
{
// No body or empty body
return new MethodInfo(filePath, lineNumber, 0, IsStub: true, IsPartial: false, StatementCount: 0);
}
var bodyLines = body.GetLocation().GetLineSpan();
var bodyLineCount = bodyLines.EndLinePosition.Line - bodyLines.StartLinePosition.Line - 1; // exclude braces
var statements = body.Statements;
var hasNotImplemented = statements.Any(s => IsNotImplementedStatement(s));
var meaningfulCount = statements.Count(s => !IsNotImplementedStatement(s));
// Pure stub: single throw NotImplementedException
if (statements.Count == 1 && hasNotImplemented)
return new MethodInfo(filePath, lineNumber, bodyLineCount, IsStub: true, IsPartial: false, StatementCount: 0);
// Partial: has some logic AND a NotImplementedException
if (hasNotImplemented && meaningfulCount > 0)
return new MethodInfo(filePath, lineNumber, bodyLineCount, IsStub: false, IsPartial: true, StatementCount: meaningfulCount);
// Real logic
return new MethodInfo(filePath, lineNumber, bodyLineCount, IsStub: false, IsPartial: false, StatementCount: meaningfulCount);
}
private MethodInfo AnalyzeProperty(string filePath, PropertyDeclarationSyntax prop)
{
var lineSpan = prop.GetLocation().GetLineSpan();
var lineNumber = lineSpan.StartLinePosition.Line + 1;
// Expression-bodied property: int Foo => expr;
if (prop.ExpressionBody is not null)
{
var isStub = IsNotImplementedExpression(prop.ExpressionBody.Expression);
return new MethodInfo(filePath, lineNumber, 1, IsStub: isStub, IsPartial: false, StatementCount: isStub ? 0 : 1);
}
// Auto-property: int Foo { get; set; } — this is valid, not a stub
if (prop.AccessorList is not null && prop.AccessorList.Accessors.All(a => a.Body is null && a.ExpressionBody is null))
return new MethodInfo(filePath, lineNumber, 0, IsStub: false, IsPartial: false, StatementCount: 1);
// Property with accessor bodies — check if any are stubs
if (prop.AccessorList is not null)
{
var hasStub = prop.AccessorList.Accessors.Any(a =>
(a.ExpressionBody is not null && IsNotImplementedExpression(a.ExpressionBody.Expression)) ||
(a.Body is not null && a.Body.Statements.Count == 1 && IsNotImplementedStatement(a.Body.Statements[0])));
return new MethodInfo(filePath, lineNumber, 0, IsStub: hasStub, IsPartial: false, StatementCount: hasStub ? 0 : 1);
}
return new MethodInfo(filePath, lineNumber, 0, IsStub: false, IsPartial: false, StatementCount: 1);
}
private static bool IsNotImplementedExpression(ExpressionSyntax expr)
{
// throw new NotImplementedException(...)
if (expr is ThrowExpressionSyntax throwExpr)
return throwExpr.Expression is ObjectCreationExpressionSyntax oc
&& oc.Type.ToString().Contains("NotImplementedException");
// new NotImplementedException() — shouldn't normally be standalone but handle it
return expr is ObjectCreationExpressionSyntax oc2
&& oc2.Type.ToString().Contains("NotImplementedException");
}
private static bool IsNotImplementedStatement(StatementSyntax stmt)
{
// throw new NotImplementedException(...);
if (stmt is ThrowStatementSyntax throwStmt && throwStmt.Expression is not null)
return throwStmt.Expression is ObjectCreationExpressionSyntax oc
&& oc.Type.ToString().Contains("NotImplementedException");
// Expression statement containing throw expression
if (stmt is ExpressionStatementSyntax exprStmt)
return IsNotImplementedExpression(exprStmt.Expression);
return false;
}
private void AddToIndex(string className, string memberName, MethodInfo info)
{
var key = (className, memberName);
if (!_index.TryGetValue(key, out var list))
{
list = [];
_index[key] = list;
}
list.Add(info);
MethodsIndexed++;
}
}
```
**Step 2: Build to verify compilation**
Run: `dotnet build --project tools/NatsNet.PortTracker`
Expected: Build succeeded. 0 Error(s).
**Step 3: Commit**
```bash
git add tools/NatsNet.PortTracker/Audit/SourceIndexer.cs
git commit -m "feat: add SourceIndexer — Roslyn-based .NET source parser for audit"
```
---
### Task 2: Create FeatureClassifier — classification heuristics
**Files:**
- Create: `tools/NatsNet.PortTracker/Audit/FeatureClassifier.cs`
**Step 1: Create the FeatureClassifier with n_a lookup and heuristics**
Create `tools/NatsNet.PortTracker/Audit/FeatureClassifier.cs`:
```csharp
namespace NatsNet.PortTracker.Audit;
/// <summary>
/// Classifies features by inspecting the SourceIndexer for their .NET implementation status.
/// Priority: n_a lookup → method-not-found → stub detection → verified.
/// </summary>
public sealed class FeatureClassifier
{
public record ClassificationResult(string Status, string Reason);
public record FeatureRecord(
long Id,
string DotnetClass,
string DotnetMethod,
string GoFile,
string GoMethod);
private readonly SourceIndexer _indexer;
// N/A lookup: (goMethod pattern) -> reason
// Checked case-insensitively against go_method
private static readonly Dictionary<string, string> NaByGoMethod = new(StringComparer.OrdinalIgnoreCase)
{
["Noticef"] = ".NET uses Microsoft.Extensions.Logging",
["Debugf"] = ".NET uses Microsoft.Extensions.Logging",
["Tracef"] = ".NET uses Microsoft.Extensions.Logging",
["Warnf"] = ".NET uses Microsoft.Extensions.Logging",
["Errorf"] = ".NET uses Microsoft.Extensions.Logging",
["Fatalf"] = ".NET uses Microsoft.Extensions.Logging",
};
// N/A lookup: go_file + go_method patterns
private static readonly List<(Func<FeatureRecord, bool> Match, string Reason)> NaPatterns =
[
// Signal handling — .NET uses IHostApplicationLifetime
(f => f.GoMethod.Equals("handleSignals", StringComparison.OrdinalIgnoreCase), ".NET uses IHostApplicationLifetime"),
(f => f.GoMethod.Equals("processSignal", StringComparison.OrdinalIgnoreCase), ".NET uses IHostApplicationLifetime"),
];
public FeatureClassifier(SourceIndexer indexer)
{
_indexer = indexer;
}
/// <summary>
/// Classify a single feature. Returns status and reason.
/// </summary>
public ClassificationResult Classify(FeatureRecord feature)
{
// 1. N/A lookup — check go_method against known patterns
if (NaByGoMethod.TryGetValue(feature.GoMethod, out var naReason))
return new ClassificationResult("n_a", naReason);
foreach (var (match, reason) in NaPatterns)
{
if (match(feature))
return new ClassificationResult("n_a", reason);
}
// 2. Handle comma-separated dotnet_class (e.g. "ClosedRingBuffer,ClosedClient")
var classNames = feature.DotnetClass.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var methodName = feature.DotnetMethod;
// Try each class name
foreach (var className in classNames)
{
var methods = _indexer.Lookup(className, methodName);
if (methods.Count > 0)
{
// Found the method — classify based on body analysis
// Use the "best" match: prefer non-stub over stub
var best = methods.OrderByDescending(m => m.StatementCount).First();
if (best.IsStub)
return new ClassificationResult("stub", $"Body is throw NotImplementedException at {Path.GetFileName(best.FilePath)}:{best.LineNumber}");
if (best.IsPartial)
return new ClassificationResult("stub", $"Partial implementation with NotImplementedException at {Path.GetFileName(best.FilePath)}:{best.LineNumber}");
return new ClassificationResult("verified", $"Method found with {best.StatementCount} statement(s) at {Path.GetFileName(best.FilePath)}:{best.LineNumber}");
}
}
// 3. Method not found — check if any class exists
var anyClassFound = classNames.Any(c => _indexer.HasClass(c));
if (anyClassFound)
return new ClassificationResult("deferred", "Class exists but method not found");
return new ClassificationResult("deferred", "Class not found in .NET source");
}
}
```
**Step 2: Build to verify compilation**
Run: `dotnet build --project tools/NatsNet.PortTracker`
Expected: Build succeeded. 0 Error(s).
**Step 3: Commit**
```bash
git add tools/NatsNet.PortTracker/Audit/FeatureClassifier.cs
git commit -m "feat: add FeatureClassifier — heuristic-based feature classification"
```
---
### Task 3: Create AuditCommand — CLI wiring and orchestration
**Files:**
- Create: `tools/NatsNet.PortTracker/Commands/AuditCommand.cs`
- Modify: `tools/NatsNet.PortTracker/Program.cs:36` — add `AuditCommand` to root command
**Step 1: Create the AuditCommand**
Create `tools/NatsNet.PortTracker/Commands/AuditCommand.cs`:
```csharp
using System.CommandLine;
using System.Text;
using NatsNet.PortTracker.Audit;
using NatsNet.PortTracker.Data;
namespace NatsNet.PortTracker.Commands;
public static class AuditCommand
{
public static Command Create(Option<string> dbOption)
{
var sourceOpt = new Option<string>("--source")
{
Description = "Path to the .NET source directory",
DefaultValueFactory = _ => Path.Combine(Directory.GetCurrentDirectory(), "dotnet", "src", "ZB.MOM.NatsNet.Server")
};
var outputOpt = new Option<string>("--output")
{
Description = "CSV report output path",
DefaultValueFactory = _ => Path.Combine(Directory.GetCurrentDirectory(), "reports", "audit-results.csv")
};
var moduleOpt = new Option<int?>("--module")
{
Description = "Restrict to a specific module ID"
};
var executeOpt = new Option<bool>("--execute")
{
Description = "Apply DB updates (default: dry-run preview)",
DefaultValueFactory = _ => false
};
var cmd = new Command("audit", "Classify unknown features by inspecting .NET source code");
cmd.Add(sourceOpt);
cmd.Add(outputOpt);
cmd.Add(moduleOpt);
cmd.Add(executeOpt);
cmd.SetAction(parseResult =>
{
var dbPath = parseResult.GetValue(dbOption)!;
var sourcePath = parseResult.GetValue(sourceOpt)!;
var outputPath = parseResult.GetValue(outputOpt)!;
var moduleId = parseResult.GetValue(moduleOpt);
var execute = parseResult.GetValue(executeOpt);
RunAudit(dbPath, sourcePath, outputPath, moduleId, execute);
});
return cmd;
}
private static void RunAudit(string dbPath, string sourcePath, string outputPath, int? moduleId, bool execute)
{
// Validate source directory
if (!Directory.Exists(sourcePath))
{
Console.WriteLine($"Error: source directory not found: {sourcePath}");
return;
}
// 1. Build source index
Console.WriteLine($"Parsing .NET source files in {sourcePath}...");
var indexer = new SourceIndexer();
indexer.IndexDirectory(sourcePath);
Console.WriteLine($"Indexed {indexer.FilesIndexed} files, {indexer.MethodsIndexed} methods/properties.");
// 2. Query unknown features
using var db = new Database(dbPath);
var sql = "SELECT id, dotnet_class, dotnet_method, go_file, go_method FROM features WHERE status = 'unknown'";
var parameters = new List<(string, object?)>();
if (moduleId is not null)
{
sql += " AND module_id = @module";
parameters.Add(("@module", moduleId));
}
sql += " ORDER BY id";
var rows = db.Query(sql, parameters.ToArray());
if (rows.Count == 0)
{
Console.WriteLine("No unknown features found.");
return;
}
Console.WriteLine($"Found {rows.Count} unknown features to classify.\n");
// 3. Classify each feature
var classifier = new FeatureClassifier(indexer);
var results = new List<(FeatureClassifier.FeatureRecord Feature, FeatureClassifier.ClassificationResult Result)>();
foreach (var row in rows)
{
var feature = new FeatureClassifier.FeatureRecord(
Id: Convert.ToInt64(row["id"]),
DotnetClass: row["dotnet_class"]?.ToString() ?? "",
DotnetMethod: row["dotnet_method"]?.ToString() ?? "",
GoFile: row["go_file"]?.ToString() ?? "",
GoMethod: row["go_method"]?.ToString() ?? "");
var result = classifier.Classify(feature);
results.Add((feature, result));
}
// 4. Write CSV report
WriteCsvReport(outputPath, results);
// 5. Print console summary
var grouped = results.GroupBy(r => r.Result.Status)
.ToDictionary(g => g.Key, g => g.Count());
Console.WriteLine("Feature Status Audit Results");
Console.WriteLine("=============================");
Console.WriteLine($"Source: {sourcePath} ({indexer.FilesIndexed} files, {indexer.MethodsIndexed} methods indexed)");
Console.WriteLine($"Features audited: {results.Count}");
Console.WriteLine();
Console.WriteLine($" verified: {grouped.GetValueOrDefault("verified", 0)}");
Console.WriteLine($" stub: {grouped.GetValueOrDefault("stub", 0)}");
Console.WriteLine($" n_a: {grouped.GetValueOrDefault("n_a", 0)}");
Console.WriteLine($" deferred: {grouped.GetValueOrDefault("deferred", 0)}");
Console.WriteLine();
if (!execute)
{
Console.WriteLine("Dry-run mode. Add --execute to apply changes.");
Console.WriteLine($"Report: {outputPath}");
return;
}
// 6. Apply DB updates
ApplyUpdates(db, results);
Console.WriteLine($"Report: {outputPath}");
}
private static void WriteCsvReport(
string outputPath,
List<(FeatureClassifier.FeatureRecord Feature, FeatureClassifier.ClassificationResult Result)> results)
{
// Ensure directory exists
var dir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var sb = new StringBuilder();
sb.AppendLine("id,dotnet_class,dotnet_method,go_file,go_method,old_status,new_status,reason");
foreach (var (feature, result) in results)
{
sb.AppendLine($"{feature.Id},{CsvEscape(feature.DotnetClass)},{CsvEscape(feature.DotnetMethod)},{CsvEscape(feature.GoFile)},{CsvEscape(feature.GoMethod)},unknown,{result.Status},{CsvEscape(result.Reason)}");
}
File.WriteAllText(outputPath, sb.ToString());
}
private static void ApplyUpdates(
Database db,
List<(FeatureClassifier.FeatureRecord Feature, FeatureClassifier.ClassificationResult Result)> results)
{
// Group by (status, notes) for efficient batch updates
var groups = results
.GroupBy(r => (r.Result.Status, Notes: r.Result.Status == "n_a" ? r.Result.Reason : (string?)null))
.ToList();
var totalUpdated = 0;
using var transaction = db.Connection.BeginTransaction();
try
{
foreach (var group in groups)
{
var ids = group.Select(r => r.Feature.Id).ToList();
var status = group.Key.Status;
var notes = group.Key.Notes;
// Build parameterized IN clause
var placeholders = new List<string>();
using var cmd = db.CreateCommand("");
for (var i = 0; i < ids.Count; i++)
{
placeholders.Add($"@id{i}");
cmd.Parameters.AddWithValue($"@id{i}", ids[i]);
}
cmd.Parameters.AddWithValue("@status", status);
if (notes is not null)
{
cmd.CommandText = $"UPDATE features SET status = @status, notes = @notes WHERE id IN ({string.Join(", ", placeholders)})";
cmd.Parameters.AddWithValue("@notes", notes);
}
else
{
cmd.CommandText = $"UPDATE features SET status = @status WHERE id IN ({string.Join(", ", placeholders)})";
}
cmd.Transaction = transaction;
var affected = cmd.ExecuteNonQuery();
totalUpdated += affected;
}
transaction.Commit();
Console.WriteLine($"Updated {totalUpdated} features.");
}
catch
{
transaction.Rollback();
Console.WriteLine("Error: transaction rolled back.");
throw;
}
}
private static string CsvEscape(string value)
{
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
}
```
**Step 2: Wire the command into Program.cs**
In `tools/NatsNet.PortTracker/Program.cs`, add after the existing command registrations (after line 41, before `var parseResult`):
Find this line:
```csharp
rootCommand.Add(PhaseCommands.Create(dbOption, schemaOption));
```
Add immediately after it:
```csharp
rootCommand.Add(AuditCommand.Create(dbOption));
```
Also add the import — but since the file uses top-level statements and already imports `NatsNet.PortTracker.Commands`, no new using is needed (AuditCommand is in the same namespace).
**Step 3: Build to verify compilation**
Run: `dotnet build --project tools/NatsNet.PortTracker`
Expected: Build succeeded. 0 Error(s).
**Step 4: Commit**
```bash
git add tools/NatsNet.PortTracker/Commands/AuditCommand.cs tools/NatsNet.PortTracker/Program.cs
git commit -m "feat: add audit command — orchestrates feature status classification"
```
---
### Task 4: Smoke test — dry-run on the real database
**Files:** None — testing only.
**Step 1: Run the audit in dry-run mode**
```bash
dotnet run --project tools/NatsNet.PortTracker -- audit --source dotnet/src/ZB.MOM.NatsNet.Server/ --db porting.db --output reports/audit-results.csv
```
Expected output similar to:
```
Parsing .NET source files in dotnet/src/ZB.MOM.NatsNet.Server/...
Indexed ~92 files, ~NNNN methods/properties.
Found 3394 unknown features to classify.
Feature Status Audit Results
=============================
Source: dotnet/src/ZB.MOM.NatsNet.Server/ (92 files, NNNN methods indexed)
Features audited: 3394
verified: NNNN
stub: NNNN
n_a: NNNN
deferred: NNNN
Dry-run mode. Add --execute to apply changes.
Report: reports/audit-results.csv
```
**Step 2: Inspect the CSV report**
```bash
head -20 reports/audit-results.csv
```
Verify:
- Header row matches: `id,dotnet_class,dotnet_method,go_file,go_method,old_status,new_status,reason`
- Each row has a classification and reason
- The known n_a features (Noticef, Debugf etc.) show as `n_a`
**Step 3: Spot-check a few classifications**
Pick 3-5 features from the CSV and manually verify:
- A `verified` feature: check the .NET method has real logic
- A `stub` feature: check the .NET method is `throw new NotImplementedException`
- A `deferred` feature: check the class/method doesn't exist
- An `n_a` feature: check it's a Go logging function
If any classifications are wrong, fix the heuristics before proceeding.
**Step 4: Check the counts add up**
```bash
wc -l reports/audit-results.csv
```
Expected: 3395 lines (3394 data rows + 1 header).
---
### Task 5: Execute the audit and update the database
**Files:** None — execution only.
**Step 1: Back up the database**
```bash
cp porting.db porting.db.pre-audit-backup
```
**Step 2: Run with --execute**
```bash
dotnet run --project tools/NatsNet.PortTracker -- audit --source dotnet/src/ZB.MOM.NatsNet.Server/ --db porting.db --output reports/audit-results.csv --execute
```
Expected: `Updated 3394 features.`
**Step 3: Verify zero unknown features remain**
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature list --status unknown --db porting.db
```
Expected: `Total: 0 features`
**Step 4: Verify status breakdown**
```bash
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
```
Review the numbers match the dry-run output.
**Step 5: Generate updated porting report**
```bash
./reports/generate-report.sh
```
**Step 6: Commit everything**
```bash
git add porting.db reports/ tools/NatsNet.PortTracker/
git commit -m "feat: run feature status audit — classify 3394 unknown features
Automated classification using Roslyn syntax tree analysis:
verified: NNNN (update with actual numbers)
stub: NNNN
n_a: NNNN
deferred: NNNN"
```
(Update the commit message with the actual numbers from the output.)
---
### Task 6: Cleanup — remove backup
**Files:** None.
**Step 1: Verify everything is committed and the database is correct**
```bash
git status
dotnet run --project tools/NatsNet.PortTracker -- feature list --status unknown --db porting.db
```
Expected: clean working tree, 0 unknown features.
**Step 2: Remove the pre-audit backup**
```bash
rm porting.db.pre-audit-backup
```
**Step 3: Final summary**
Print:
```
Feature Status Audit Complete
=============================
Total features audited: 3394
verified: NNNN
stub: NNNN
n_a: NNNN
deferred: NNNN
```

View File

@@ -0,0 +1,13 @@
{
"planPath": "docs/plans/2026-02-27-feature-audit-script-plan.md",
"tasks": [
{"id": 0, "subject": "Task 0: Add Roslyn NuGet package", "status": "pending"},
{"id": 1, "subject": "Task 1: Create SourceIndexer", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: Create FeatureClassifier", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Create AuditCommand + wire CLI", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: Smoke test dry-run", "status": "pending", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: Execute audit and update DB", "status": "pending", "blockedBy": [4]},
{"id": 6, "subject": "Task 6: Cleanup and final verification", "status": "pending", "blockedBy": [5]}
],
"lastUpdated": "2026-02-27T00:00:00Z"
}

View File

@@ -0,0 +1,106 @@
# Feature Status Audit Design
**Date:** 2026-02-27
**Status:** Approved
## Problem
3394 features in module 8 (`server`) are marked as `unknown` status after a bulk reclassification. Each needs to be checked against its .NET implementation to determine the correct status.
## Scope
- **Module:** 8 (server) — all 3394 unknown features
- **Go source files:** 64 distinct files
- **All features have `dotnet_class` and `dotnet_method` mappings** — no unmapped features
## Classification Criteria
| Status | Criteria | Example |
|--------|----------|---------|
| `verified` | .NET method exists with non-trivial logic matching Go behavior | `MemStore.StoreRawMsg` — full implementation |
| `stub` | .NET method exists but is `throw new NotImplementedException()`, empty, or only partially implemented | `FileStore.Compact` — no real logic |
| `n_a` | Go feature doesn't apply to .NET — .NET uses a different approach (different library, runtime feature, or platform pattern) | Go logging functions → .NET uses `Microsoft.Extensions.Logging` |
| `deferred` | .NET method doesn't exist, or classification requires running the server end-to-end | Server-integration features needing full runtime |
**Partial implementations** (method exists with some logic but missing significant functionality) are classified as `stub`.
## Batch Execution Process
Features are processed in fixed batches of 50. Each batch follows this workflow:
### Step 1: Fetch next 50 unknown features
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status unknown --db porting.db
```
Take the first 50 IDs from the output.
### Step 2: Inspect .NET source for each feature
For each feature:
1. Read the `dotnet_class` and `dotnet_method` from the feature record
2. Find the .NET source file containing that class
3. Check the method body:
- Real logic matching Go = `verified`
- Stub / empty / partial = `stub`
- .NET alternative exists = `n_a`
- Method not found = `deferred`
### Step 3: Dry-run the batch update (MANDATORY)
Group features by their determined status and dry-run using PortTracker:
```bash
# Dry-run — verify correct features affected
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids 150-160 --set-status deferred --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids 2068-2077 --set-status verified --db porting.db
```
Review the preview output. Only proceed if the listed features match expectations.
### Step 4: Execute once dry-run verified
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids 150-160 --set-status deferred --execute --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids 2068-2077 --set-status verified --execute --db porting.db
```
### Step 5: Verify remaining count
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature list --status unknown --db porting.db
```
Confirm the count decreased by ~50.
## Rules
1. **ALWAYS dry-run before executing** — no exceptions
2. **NEVER use direct SQL** (`sqlite3`) — use PortTracker CLI exclusively
3. **Process exactly 50 per batch** (or fewer if fewer remain)
4. **Report classification breakdown** after each batch (e.g. "Batch 3: 12 verified, 30 stub, 3 n_a, 5 deferred")
5. **68 batches total** (3394 / 50 = ~68)
## Key .NET Source Locations
```
dotnet/src/ZB.MOM.NatsNet.Server/
Accounts/Account.cs, AccountResolver.cs, DirJwtStore.cs
Auth/AuthHandler.cs, JwtProcessor.cs
Config/ReloadOptions.cs, ServerOptionsConfiguration.cs
JetStream/MemStore.cs, FileStore.cs, JetStreamTypes.cs
JetStream/NatsStream.cs, NatsConsumer.cs, RaftTypes.cs
Protocol/ProtocolParser.cs, ProxyProtocol.cs
Routes/RouteTypes.cs, LeafNode/LeafNodeTypes.cs, Gateway/GatewayTypes.cs
Mqtt/MqttHandler.cs, WebSocket/WebSocketTypes.cs
Internal/ (various data structures)
NatsServer.cs, NatsServer.*.cs (partial class files)
ClientConnection.cs
```
## Non-Goals
- No code changes — this is classification only
- No unit_tests reclassification (separate effort)
- No schema changes needed (`unknown` already added)

View File

@@ -0,0 +1,236 @@
# Feature Status Audit Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Classify 3394 features currently marked `unknown` into the correct status (`verified`, `stub`, `n_a`, or `deferred`) by inspecting .NET source code against Go feature specifications.
**Architecture:** Process features in sequential batches of 50. Each batch: fetch 50 unknown features via PortTracker CLI, inspect the corresponding .NET source files, classify each feature, dry-run the batch updates, then execute. Repeat until zero unknown features remain.
**Tech Stack:** PortTracker CLI (`dotnet run --project tools/NatsNet.PortTracker`), .NET source at `dotnet/src/ZB.MOM.NatsNet.Server/`
**Design doc:** `docs/plans/2026-02-27-feature-status-audit-design.md`
---
## Important Rules (Read Before Every Task)
1. **ALWAYS dry-run before executing** — no exceptions. Every `batch-update` command must be run WITHOUT `--execute` first to preview.
2. **NEVER use direct SQL** (`sqlite3`) — use the PortTracker CLI exclusively for all database operations.
3. **Process exactly 50 per batch** (or fewer if fewer remain in the final batch).
4. **Report classification breakdown** after each batch (e.g. "Batch 3: 12 verified, 30 stub, 3 n_a, 5 deferred").
## Classification Criteria Reference
| Status | Criteria |
|--------|----------|
| `verified` | .NET method exists with non-trivial logic that matches the Go implementation's behavior |
| `stub` | .NET method exists but is `throw new NotImplementedException()`, empty, or only **partially** implemented (has structure but missing significant logic) |
| `n_a` | Go feature doesn't apply to .NET — .NET uses a different approach (e.g. Go logging → .NET uses `Microsoft.Extensions.Logging`) |
| `deferred` | .NET method doesn't exist at all, or classification requires the server running end-to-end |
## Key .NET Source Locations
When looking for a `dotnet_class`, search in these directories:
```
dotnet/src/ZB.MOM.NatsNet.Server/
Accounts/ — Account, AccountResolver, DirJwtStore, AccountTypes
Auth/ — AuthHandler, JwtProcessor, CipherSuites, AuthTypes
Config/ — ReloadOptions, ServerOptionsConfiguration, NatsJsonConverters
Events/ — EventTypes
Gateway/ — GatewayTypes
Internal/ — Subscription, WaitGroup, ClosedRingBuffer, RateCounter, DataStructures/
JetStream/ — MemStore, ConsumerMemStore, FileStore, FileStoreTypes, MessageBlock
JetStreamTypes, JetStreamApiTypes, JetStreamErrors, JetStreamVersioning
NatsStream, NatsConsumer, RaftTypes, JetStreamClusterTypes
LeafNode/ — LeafNodeTypes
MessageTrace/ — MsgTraceTypes
Monitor/ — MonitorTypes, MonitorSortOptions
Mqtt/ — MqttConstants, MqttTypes, MqttHandler
Protocol/ — ParserTypes, ProtocolParser, ProxyProtocol
Routes/ — RouteTypes
WebSocket/ — WebSocketConstants, WebSocketTypes
NatsServer.cs, NatsServer.Auth.cs, NatsServer.Signals.cs, NatsServer.Init.cs
NatsServer.Accounts.cs, NatsServer.Lifecycle.cs, NatsServer.Listeners.cs
ClientConnection.cs, ClientTypes.cs, NatsMessageHeaders.cs
```
---
### Task 0: Verify starting state and PortTracker commands
**Files:** None — verification only.
**Step 1: Check how many unknown features exist**
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status unknown --db porting.db
```
Expected: Output shows ~3394 features. Note the total count at the bottom.
**Step 2: Verify batch-update dry-run works**
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids 150-152 --set-status verified --db porting.db
```
Expected: Preview output showing 3 features. Should say "Would affect 3 items:" and "Add --execute to apply these changes." Do NOT execute — this is just confirming the tool works.
**Step 3: Record the starting count**
Note the total unknown count. This is your baseline. After all batches complete, the count should be 0.
---
### Task N (repeat for N=1 through 68): Process batch of 50 unknown features
> **This task is a template.** Repeat it until zero unknown features remain. Each execution processes the next 50.
**Files:** None — classification only, no code changes.
**Step 1: Fetch the next 50 unknown features**
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status unknown --db porting.db
```
From the output, take the **first 50 feature IDs**. Note the `dotnet_class` and `dotnet_method` columns for each.
**Step 2: For each feature, inspect the .NET implementation**
For each of the 50 features:
1. **Find the .NET source file** — use `Grep` to search for the class:
```
Grep pattern: "class {dotnet_class}" path: dotnet/src/ZB.MOM.NatsNet.Server/
```
2. **Find the method** — search within that file for the method name:
```
Grep pattern: "{dotnet_method}" path: {the file found above}
```
3. **Read the method body** — use `Read` to view the method implementation.
4. **Classify the feature:**
- If the method has real, non-trivial logic matching the Go behavior → `verified`
- If the method is `throw new NotImplementedException()`, empty, or only partially there → `stub`
- If the Go feature has a .NET-native replacement (e.g., Go's custom logging → `Microsoft.Extensions.Logging`, Go's `sync.Mutex` → C#'s `Lock`) → `n_a`
- If the method doesn't exist in the .NET codebase at all → `deferred`
**Efficiency tip:** Features from the same `dotnet_class` should be inspected together — read the .NET file once and classify all features from that class in the batch.
**Step 3: Group IDs by classification result**
After inspecting all 50, organize the IDs into groups:
```
verified_ids: 2068,2069,2070,2071,...
stub_ids: 2080,2081,...
n_a_ids: 2090,...
deferred_ids: 2095,2096,...
```
**Step 4: Dry-run each group (MANDATORY — DO NOT SKIP)**
Run the dry-run for EACH status group. Review the output carefully.
```bash
# Dry-run verified
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {verified_ids} --set-status verified --db porting.db
# Dry-run stub
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {stub_ids} --set-status stub --db porting.db
# Dry-run n_a (include reason in notes)
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {n_a_ids} --set-status n_a --set-notes "{reason}" --db porting.db
# Dry-run deferred
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {deferred_ids} --set-status deferred --db porting.db
```
Check that:
- The feature names in the preview match what you inspected
- The count per group adds up to 50 (or the batch size)
- No unexpected features appear
**Step 5: Execute each group**
Only after verifying ALL dry-runs look correct:
```bash
# Execute verified
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {verified_ids} --set-status verified --execute --db porting.db
# Execute stub
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {stub_ids} --set-status stub --execute --db porting.db
# Execute n_a (with notes)
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {n_a_ids} --set-status n_a --set-notes "{reason}" --execute --db porting.db
# Execute deferred
dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --ids {deferred_ids} --set-status deferred --execute --db porting.db
```
**Step 6: Verify remaining count decreased**
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature list --status unknown --db porting.db
```
Confirm the total decreased by ~50 from the previous batch.
**Step 7: Report batch summary**
Print: `Batch N: X verified, Y stub, Z n_a, W deferred (Total remaining: NNNN)`
---
### Task 69: Final verification and report
**Files:** None — verification only.
**Step 1: Confirm zero unknown features remain**
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature list --status unknown --db porting.db
```
Expected: `Total: 0 features`
**Step 2: Generate the full status breakdown**
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status verified --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status stub --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status n_a --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- feature list --module 8 --status deferred --db porting.db
```
Note the count for each status.
**Step 3: Generate updated porting report**
```bash
./reports/generate-report.sh
```
**Step 4: Commit the updated report**
```bash
git add reports/
git commit -m "chore: update porting report after feature status audit"
```
**Step 5: Print final summary**
```
Feature Status Audit Complete
=============================
Total features audited: 3394
verified: NNNN
stub: NNNN
n_a: NNNN
deferred: NNNN
```

View File

@@ -0,0 +1,9 @@
{
"planPath": "docs/plans/2026-02-27-feature-status-audit-plan.md",
"tasks": [
{"id": 0, "subject": "Task 0: Verify starting state and PortTracker commands", "status": "pending"},
{"id": 1, "subject": "Task 1-68: Process batches of 50 unknown features (repeating template)", "status": "pending", "blockedBy": [0], "note": "This is a repeating task — execute the template from the plan 68 times until 0 unknown features remain"},
{"id": 69, "subject": "Task 69: Final verification and report", "status": "pending", "blockedBy": [1]}
],
"lastUpdated": "2026-02-27T00:00:00Z"
}

View File

@@ -0,0 +1,120 @@
# PortTracker Batch Operations Design
**Date:** 2026-02-27
**Status:** Approved
## Problem
The PortTracker CLI only supports one-at-a-time operations for status updates, mappings, and N/A marking. With ~3700 features and ~3300 tests, bulk operations require dropping to raw `sqlite3` commands. This is error-prone and bypasses any validation the CLI could provide.
## Design
### Approach
Add `batch-update` and `batch-map` subcommands under each existing entity command (`feature`, `test`, `module`, `library`). All batch commands share a common filter + dry-run infrastructure.
### Shared Batch Infrastructure
A new `BatchFilters` static class in `Commands/BatchFilters.cs` provides:
**Filter Options** (combined with AND logic):
- `--ids <range>` — ID range like `100-200`, comma-separated `1,5,10`, or mixed `1-5,10,20-25`
- `--module <id>` — filter by module_id (feature/test only)
- `--status <status>` — filter by current status value
**Dry-Run Default:**
- Without `--execute`, commands show a preview: "Would affect N items:" + table of matching rows
- With `--execute`, changes are applied inside a transaction and "Updated N items." is printed
- At least one filter is required (no accidental "update everything" with zero filters)
**Shared Methods:**
- `AddFilterOptions(Command cmd, bool includeModuleFilter)` — adds the common options to a command
- `BuildWhereClause(...)` — returns SQL WHERE clause + parameters from parsed filter values
- `PreviewOrExecute(Database db, string table, string selectSql, string updateSql, params[], bool execute)` — handles dry-run preview vs actual execution
### Feature Batch Commands
**`feature batch-update`**
- Filters: `--ids`, `--module`, `--status`
- Setters: `--set-status` (required), `--set-notes` (optional)
- Flag: `--execute`
**`feature batch-map`**
- Filters: `--ids`, `--module`, `--status`
- Setters: `--set-project`, `--set-class`, `--set-method` (at least one required)
- Flag: `--execute`
### Test Batch Commands
**`test batch-update`**
- Filters: `--ids`, `--module`, `--status`
- Setters: `--set-status` (required), `--set-notes` (optional)
- Flag: `--execute`
**`test batch-map`**
- Filters: `--ids`, `--module`, `--status`
- Setters: `--set-project`, `--set-class`, `--set-method` (at least one required)
- Flag: `--execute`
### Module Batch Commands
**`module batch-update`**
- Filters: `--ids`, `--status`
- Setters: `--set-status` (required), `--set-notes` (optional)
- Flag: `--execute`
**`module batch-map`**
- Filters: `--ids`, `--status`
- Setters: `--set-project`, `--set-namespace`, `--set-class` (at least one required)
- Flag: `--execute`
### Library Batch Commands
**`library batch-update`**
- Filters: `--ids`, `--status`
- Setters: `--set-status` (required), `--set-notes` (optional, maps to `dotnet_usage_notes`)
- Flag: `--execute`
**`library batch-map`**
- Filters: `--ids`, `--status`
- Setters: `--set-package`, `--set-namespace`, `--set-notes` (at least one required)
- Flag: `--execute`
## Examples
```bash
# Preview: which features in module 5 are not_started?
porttracker feature batch-update --module 5 --status not_started --set-status deferred
# Execute: defer all features in module 5 with a reason
porttracker feature batch-update --module 5 --status not_started --set-status deferred --set-notes "needs server runtime" --execute
# Execute: mark tests 500-750 as deferred
porttracker test batch-update --ids 500-750 --set-status deferred --set-notes "server-integration" --execute
# Execute: batch-map all features in module 3 to a .NET project
porttracker feature batch-map --module 3 --set-project "ZB.MOM.NatsNet.Server" --execute
# Preview: what libraries are unmapped?
porttracker library batch-update --status not_mapped --set-status mapped
# Execute: batch-map libraries
porttracker library batch-map --ids 1-20 --set-package "Microsoft.Extensions.Logging" --set-namespace "Microsoft.Extensions.Logging" --execute
```
## File Changes
| File | Change |
|------|--------|
| `Commands/BatchFilters.cs` | New — shared filter options, WHERE builder, preview/execute logic |
| `Commands/FeatureCommands.cs` | Add `batch-update` and `batch-map` subcommands |
| `Commands/TestCommands.cs` | Add `batch-update` and `batch-map` subcommands |
| `Commands/ModuleCommands.cs` | Add `batch-update` and `batch-map` subcommands |
| `Commands/LibraryCommands.cs` | Add `batch-update` and `batch-map` subcommands |
| `Data/Database.cs` | Add `ExecuteInTransaction` helper for batch safety |
## Non-Goals
- No batch create or batch delete — not needed for the porting workflow
- No raw `--where` SQL escape hatch — structured filters cover all use cases
- No interactive y/n prompts — dry-run + `--execute` flag is sufficient and scriptable

View File

@@ -0,0 +1,919 @@
# PortTracker Batch Operations Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Add batch-update and batch-map subcommands to all PortTracker entity commands (feature, test, module, library) with shared filter infrastructure and dry-run-by-default safety.
**Architecture:** A shared `BatchFilters` static class provides reusable filter options (`--ids`, `--module`, `--status`), WHERE clause building, and the dry-run/execute pattern. Each entity command file gets two new subcommands that delegate filtering and execution to `BatchFilters`. The `Database` class gets an `ExecuteInTransaction` helper.
**Tech Stack:** .NET 10, System.CommandLine v3 preview, Microsoft.Data.Sqlite
**Design doc:** `docs/plans/2026-02-27-porttracker-batch-design.md`
---
### Task 0: Add ExecuteInTransaction to Database
**Files:**
- Modify: `tools/NatsNet.PortTracker/Data/Database.cs:73` (before Dispose)
**Step 1: Add the method**
Add this method to `Database.cs` before the `Dispose()` method (line 73):
```csharp
public int ExecuteInTransaction(string sql, params (string name, object? value)[] parameters)
{
using var transaction = _connection.BeginTransaction();
try
{
using var cmd = CreateCommand(sql);
cmd.Transaction = transaction;
foreach (var (name, value) in parameters)
cmd.Parameters.AddWithValue(name, value ?? DBNull.Value);
var affected = cmd.ExecuteNonQuery();
transaction.Commit();
return affected;
}
catch
{
transaction.Rollback();
throw;
}
}
```
**Step 2: Verify it compiles**
Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
Expected: Build succeeded.
**Step 3: Commit**
```bash
git add tools/NatsNet.PortTracker/Data/Database.cs
git commit -m "feat(porttracker): add ExecuteInTransaction to Database"
```
---
### Task 1: Create BatchFilters shared infrastructure
**Files:**
- Create: `tools/NatsNet.PortTracker/Commands/BatchFilters.cs`
**Step 1: Create the file**
Create `tools/NatsNet.PortTracker/Commands/BatchFilters.cs` with this content:
```csharp
using System.CommandLine;
using NatsNet.PortTracker.Data;
namespace NatsNet.PortTracker.Commands;
public static class BatchFilters
{
public static Option<string?> IdsOption() => new("--ids")
{
Description = "ID range: 100-200, 1,5,10, or mixed 1-5,10,20-25"
};
public static Option<int?> ModuleOption() => new("--module")
{
Description = "Filter by module ID"
};
public static Option<string?> StatusOption() => new("--status")
{
Description = "Filter by current status"
};
public static Option<bool> ExecuteOption() => new("--execute")
{
Description = "Actually apply changes (default is dry-run preview)",
DefaultValueFactory = _ => false
};
public static void AddFilterOptions(Command cmd, bool includeModuleFilter)
{
cmd.Add(IdsOption());
if (includeModuleFilter)
cmd.Add(ModuleOption());
cmd.Add(StatusOption());
cmd.Add(ExecuteOption());
}
public static List<int> ParseIds(string? idsSpec)
{
if (string.IsNullOrWhiteSpace(idsSpec)) return [];
var ids = new List<int>();
foreach (var part in idsSpec.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (part.Contains('-'))
{
var range = part.Split('-', 2);
if (int.TryParse(range[0], out var start) && int.TryParse(range[1], out var end))
{
for (var i = start; i <= end; i++)
ids.Add(i);
}
else
{
Console.WriteLine($"Warning: invalid range '{part}', skipping.");
}
}
else if (int.TryParse(part, out var id))
{
ids.Add(id);
}
else
{
Console.WriteLine($"Warning: invalid ID '{part}', skipping.");
}
}
return ids;
}
public static (string whereClause, List<(string name, object? value)> parameters) BuildWhereClause(
string? idsSpec, int? moduleId, string? status, string idColumn = "id", string moduleColumn = "module_id")
{
var clauses = new List<string>();
var parameters = new List<(string name, object? value)>();
if (!string.IsNullOrWhiteSpace(idsSpec))
{
var ids = ParseIds(idsSpec);
if (ids.Count > 0)
{
var placeholders = new List<string>();
for (var i = 0; i < ids.Count; i++)
{
placeholders.Add($"@id{i}");
parameters.Add(($"@id{i}", ids[i]));
}
clauses.Add($"{idColumn} IN ({string.Join(", ", placeholders)})");
}
}
if (moduleId is not null)
{
clauses.Add($"{moduleColumn} = @moduleFilter");
parameters.Add(("@moduleFilter", moduleId));
}
if (!string.IsNullOrWhiteSpace(status))
{
clauses.Add("status = @statusFilter");
parameters.Add(("@statusFilter", status));
}
if (clauses.Count == 0)
return ("", parameters);
return (" WHERE " + string.Join(" AND ", clauses), parameters);
}
public static void PreviewOrExecute(
Database db,
string table,
string displayColumns,
string updateSetClause,
List<(string name, object? value)> updateParams,
string whereClause,
List<(string name, object? value)> filterParams,
bool execute)
{
// Count matching rows
var countSql = $"SELECT COUNT(*) FROM {table}{whereClause}";
var count = db.ExecuteScalar<long>(countSql, filterParams.ToArray());
if (count == 0)
{
Console.WriteLine("No items match the specified filters.");
return;
}
// Preview
var previewSql = $"SELECT {displayColumns} FROM {table}{whereClause} ORDER BY id";
var rows = db.Query(previewSql, filterParams.ToArray());
if (!execute)
{
Console.WriteLine($"Would affect {count} items:");
Console.WriteLine();
PrintPreviewTable(rows);
Console.WriteLine();
Console.WriteLine("Add --execute to apply these changes.");
return;
}
// Execute
var allParams = new List<(string name, object? value)>();
allParams.AddRange(updateParams);
allParams.AddRange(filterParams);
var updateSql = $"UPDATE {table} SET {updateSetClause}{whereClause}";
var affected = db.ExecuteInTransaction(updateSql, allParams.ToArray());
Console.WriteLine($"Updated {affected} items.");
}
private static void PrintPreviewTable(List<Dictionary<string, object?>> rows)
{
if (rows.Count == 0) return;
var columns = rows[0].Keys.ToList();
var widths = columns.Select(c => c.Length).ToList();
foreach (var row in rows)
{
for (var i = 0; i < columns.Count; i++)
{
var val = row[columns[i]]?.ToString() ?? "";
if (val.Length > widths[i]) widths[i] = Math.Min(val.Length, 40);
}
}
// Header
var header = string.Join(" ", columns.Select((c, i) => Truncate(c, widths[i]).PadRight(widths[i])));
Console.WriteLine(header);
Console.WriteLine(new string('-', header.Length));
// Rows (cap at 50 for preview)
var displayRows = rows.Take(50).ToList();
foreach (var row in displayRows)
{
var line = string.Join(" ", columns.Select((c, i) =>
Truncate(row[c]?.ToString() ?? "", widths[i]).PadRight(widths[i])));
Console.WriteLine(line);
}
if (rows.Count > 50)
Console.WriteLine($" ... and {rows.Count - 50} more");
}
private static string Truncate(string s, int maxLen)
{
return s.Length <= maxLen ? s : s[..(maxLen - 2)] + "..";
}
}
```
**Step 2: Verify it compiles**
Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
Expected: Build succeeded.
**Step 3: Commit**
```bash
git add tools/NatsNet.PortTracker/Commands/BatchFilters.cs
git commit -m "feat(porttracker): add BatchFilters shared infrastructure"
```
---
### Task 2: Add batch commands to FeatureCommands
**Files:**
- Modify: `tools/NatsNet.PortTracker/Commands/FeatureCommands.cs:169-175`
**Step 1: Add batch-update and batch-map subcommands**
In `FeatureCommands.cs`, insert the batch commands before the `return featureCommand;` line (line 175). Add them after the existing `featureCommand.Add(naCmd);` at line 173.
Replace lines 169-175 with:
```csharp
featureCommand.Add(listCmd);
featureCommand.Add(showCmd);
featureCommand.Add(updateCmd);
featureCommand.Add(mapCmd);
featureCommand.Add(naCmd);
featureCommand.Add(CreateBatchUpdate(dbOption));
featureCommand.Add(CreateBatchMap(dbOption));
return featureCommand;
```
Then add these two static methods to the class (before the `Truncate` method at line 178):
```csharp
private static Command CreateBatchUpdate(Option<string> dbOption)
{
var cmd = new Command("batch-update", "Bulk update feature status");
var idsOpt = BatchFilters.IdsOption();
var moduleOpt = BatchFilters.ModuleOption();
var statusOpt = BatchFilters.StatusOption();
var executeOpt = BatchFilters.ExecuteOption();
var setStatus = new Option<string>("--set-status") { Description = "New status to set", Required = true };
var setNotes = new Option<string?>("--set-notes") { Description = "Notes to set" };
cmd.Add(idsOpt);
cmd.Add(moduleOpt);
cmd.Add(statusOpt);
cmd.Add(executeOpt);
cmd.Add(setStatus);
cmd.Add(setNotes);
cmd.SetAction(parseResult =>
{
var dbPath = parseResult.GetValue(dbOption)!;
var ids = parseResult.GetValue(idsOpt);
var module = parseResult.GetValue(moduleOpt);
var status = parseResult.GetValue(statusOpt);
var execute = parseResult.GetValue(executeOpt);
var newStatus = parseResult.GetValue(setStatus)!;
var notes = parseResult.GetValue(setNotes);
if (string.IsNullOrWhiteSpace(ids) && module is null && string.IsNullOrWhiteSpace(status))
{
Console.WriteLine("Error: at least one filter (--ids, --module, --status) is required.");
return;
}
using var db = new Database(dbPath);
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, module, status);
var setClauses = new List<string> { "status = @newStatus" };
var updateParams = new List<(string, object?)> { ("@newStatus", newStatus) };
if (notes is not null)
{
setClauses.Add("notes = @newNotes");
updateParams.Add(("@newNotes", notes));
}
BatchFilters.PreviewOrExecute(db, "features",
"id, name, status, module_id, notes",
string.Join(", ", setClauses), updateParams,
whereClause, filterParams, execute);
});
return cmd;
}
private static Command CreateBatchMap(Option<string> dbOption)
{
var cmd = new Command("batch-map", "Bulk map features to .NET methods");
var idsOpt = BatchFilters.IdsOption();
var moduleOpt = BatchFilters.ModuleOption();
var statusOpt = BatchFilters.StatusOption();
var executeOpt = BatchFilters.ExecuteOption();
var setProject = new Option<string?>("--set-project") { Description = ".NET project" };
var setClass = new Option<string?>("--set-class") { Description = ".NET class" };
var setMethod = new Option<string?>("--set-method") { Description = ".NET method" };
cmd.Add(idsOpt);
cmd.Add(moduleOpt);
cmd.Add(statusOpt);
cmd.Add(executeOpt);
cmd.Add(setProject);
cmd.Add(setClass);
cmd.Add(setMethod);
cmd.SetAction(parseResult =>
{
var dbPath = parseResult.GetValue(dbOption)!;
var ids = parseResult.GetValue(idsOpt);
var module = parseResult.GetValue(moduleOpt);
var status = parseResult.GetValue(statusOpt);
var execute = parseResult.GetValue(executeOpt);
var project = parseResult.GetValue(setProject);
var cls = parseResult.GetValue(setClass);
var method = parseResult.GetValue(setMethod);
if (string.IsNullOrWhiteSpace(ids) && module is null && string.IsNullOrWhiteSpace(status))
{
Console.WriteLine("Error: at least one filter (--ids, --module, --status) is required.");
return;
}
if (project is null && cls is null && method is null)
{
Console.WriteLine("Error: at least one of --set-project, --set-class, --set-method is required.");
return;
}
using var db = new Database(dbPath);
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, module, status);
var setClauses = new List<string>();
var updateParams = new List<(string, object?)>();
if (project is not null) { setClauses.Add("dotnet_project = @setProject"); updateParams.Add(("@setProject", project)); }
if (cls is not null) { setClauses.Add("dotnet_class = @setClass"); updateParams.Add(("@setClass", cls)); }
if (method is not null) { setClauses.Add("dotnet_method = @setMethod"); updateParams.Add(("@setMethod", method)); }
BatchFilters.PreviewOrExecute(db, "features",
"id, name, status, dotnet_project, dotnet_class, dotnet_method",
string.Join(", ", setClauses), updateParams,
whereClause, filterParams, execute);
});
return cmd;
}
```
**Step 2: Verify it compiles**
Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
Expected: Build succeeded.
**Step 3: Smoke test dry-run**
Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --module 1 --status not_started --set-status deferred --db porting.db`
Expected: Preview output showing matching features (or "No items match").
**Step 4: Commit**
```bash
git add tools/NatsNet.PortTracker/Commands/FeatureCommands.cs
git commit -m "feat(porttracker): add feature batch-update and batch-map commands"
```
---
### Task 3: Add batch commands to TestCommands
**Files:**
- Modify: `tools/NatsNet.PortTracker/Commands/TestCommands.cs:130-135`
**Step 1: Add batch-update and batch-map subcommands**
In `TestCommands.cs`, replace lines 130-135 with:
```csharp
testCommand.Add(listCmd);
testCommand.Add(showCmd);
testCommand.Add(updateCmd);
testCommand.Add(mapCmd);
testCommand.Add(CreateBatchUpdate(dbOption));
testCommand.Add(CreateBatchMap(dbOption));
return testCommand;
```
Then add these two static methods before the `Truncate` method (line 138):
```csharp
private static Command CreateBatchUpdate(Option<string> dbOption)
{
var cmd = new Command("batch-update", "Bulk update test status");
var idsOpt = BatchFilters.IdsOption();
var moduleOpt = BatchFilters.ModuleOption();
var statusOpt = BatchFilters.StatusOption();
var executeOpt = BatchFilters.ExecuteOption();
var setStatus = new Option<string>("--set-status") { Description = "New status to set", Required = true };
var setNotes = new Option<string?>("--set-notes") { Description = "Notes to set" };
cmd.Add(idsOpt);
cmd.Add(moduleOpt);
cmd.Add(statusOpt);
cmd.Add(executeOpt);
cmd.Add(setStatus);
cmd.Add(setNotes);
cmd.SetAction(parseResult =>
{
var dbPath = parseResult.GetValue(dbOption)!;
var ids = parseResult.GetValue(idsOpt);
var module = parseResult.GetValue(moduleOpt);
var status = parseResult.GetValue(statusOpt);
var execute = parseResult.GetValue(executeOpt);
var newStatus = parseResult.GetValue(setStatus)!;
var notes = parseResult.GetValue(setNotes);
if (string.IsNullOrWhiteSpace(ids) && module is null && string.IsNullOrWhiteSpace(status))
{
Console.WriteLine("Error: at least one filter (--ids, --module, --status) is required.");
return;
}
using var db = new Database(dbPath);
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, module, status);
var setClauses = new List<string> { "status = @newStatus" };
var updateParams = new List<(string, object?)> { ("@newStatus", newStatus) };
if (notes is not null)
{
setClauses.Add("notes = @newNotes");
updateParams.Add(("@newNotes", notes));
}
BatchFilters.PreviewOrExecute(db, "unit_tests",
"id, name, status, module_id, notes",
string.Join(", ", setClauses), updateParams,
whereClause, filterParams, execute);
});
return cmd;
}
private static Command CreateBatchMap(Option<string> dbOption)
{
var cmd = new Command("batch-map", "Bulk map tests to .NET test methods");
var idsOpt = BatchFilters.IdsOption();
var moduleOpt = BatchFilters.ModuleOption();
var statusOpt = BatchFilters.StatusOption();
var executeOpt = BatchFilters.ExecuteOption();
var setProject = new Option<string?>("--set-project") { Description = ".NET test project" };
var setClass = new Option<string?>("--set-class") { Description = ".NET test class" };
var setMethod = new Option<string?>("--set-method") { Description = ".NET test method" };
cmd.Add(idsOpt);
cmd.Add(moduleOpt);
cmd.Add(statusOpt);
cmd.Add(executeOpt);
cmd.Add(setProject);
cmd.Add(setClass);
cmd.Add(setMethod);
cmd.SetAction(parseResult =>
{
var dbPath = parseResult.GetValue(dbOption)!;
var ids = parseResult.GetValue(idsOpt);
var module = parseResult.GetValue(moduleOpt);
var status = parseResult.GetValue(statusOpt);
var execute = parseResult.GetValue(executeOpt);
var project = parseResult.GetValue(setProject);
var cls = parseResult.GetValue(setClass);
var method = parseResult.GetValue(setMethod);
if (string.IsNullOrWhiteSpace(ids) && module is null && string.IsNullOrWhiteSpace(status))
{
Console.WriteLine("Error: at least one filter (--ids, --module, --status) is required.");
return;
}
if (project is null && cls is null && method is null)
{
Console.WriteLine("Error: at least one of --set-project, --set-class, --set-method is required.");
return;
}
using var db = new Database(dbPath);
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, module, status);
var setClauses = new List<string>();
var updateParams = new List<(string, object?)>();
if (project is not null) { setClauses.Add("dotnet_project = @setProject"); updateParams.Add(("@setProject", project)); }
if (cls is not null) { setClauses.Add("dotnet_class = @setClass"); updateParams.Add(("@setClass", cls)); }
if (method is not null) { setClauses.Add("dotnet_method = @setMethod"); updateParams.Add(("@setMethod", method)); }
BatchFilters.PreviewOrExecute(db, "unit_tests",
"id, name, status, dotnet_project, dotnet_class, dotnet_method",
string.Join(", ", setClauses), updateParams,
whereClause, filterParams, execute);
});
return cmd;
}
```
**Step 2: Verify it compiles**
Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
Expected: Build succeeded.
**Step 3: Smoke test dry-run**
Run: `dotnet run --project tools/NatsNet.PortTracker -- test batch-update --status not_started --set-status deferred --db porting.db`
Expected: Preview output showing matching tests (or "No items match").
**Step 4: Commit**
```bash
git add tools/NatsNet.PortTracker/Commands/TestCommands.cs
git commit -m "feat(porttracker): add test batch-update and batch-map commands"
```
---
### Task 4: Add batch commands to ModuleCommands
**Files:**
- Modify: `tools/NatsNet.PortTracker/Commands/ModuleCommands.cs:145-152`
**Step 1: Add batch-update and batch-map subcommands**
In `ModuleCommands.cs`, replace lines 145-152 with:
```csharp
moduleCommand.Add(listCmd);
moduleCommand.Add(showCmd);
moduleCommand.Add(updateCmd);
moduleCommand.Add(mapCmd);
moduleCommand.Add(naCmd);
moduleCommand.Add(CreateBatchUpdate(dbOption));
moduleCommand.Add(CreateBatchMap(dbOption));
return moduleCommand;
}
```
Then add these two static methods before the closing `}` of the class:
```csharp
private static Command CreateBatchUpdate(Option<string> dbOption)
{
var cmd = new Command("batch-update", "Bulk update module status");
var idsOpt = BatchFilters.IdsOption();
var statusOpt = BatchFilters.StatusOption();
var executeOpt = BatchFilters.ExecuteOption();
var setStatus = new Option<string>("--set-status") { Description = "New status to set", Required = true };
var setNotes = new Option<string?>("--set-notes") { Description = "Notes to set" };
cmd.Add(idsOpt);
cmd.Add(statusOpt);
cmd.Add(executeOpt);
cmd.Add(setStatus);
cmd.Add(setNotes);
cmd.SetAction(parseResult =>
{
var dbPath = parseResult.GetValue(dbOption)!;
var ids = parseResult.GetValue(idsOpt);
var status = parseResult.GetValue(statusOpt);
var execute = parseResult.GetValue(executeOpt);
var newStatus = parseResult.GetValue(setStatus)!;
var notes = parseResult.GetValue(setNotes);
if (string.IsNullOrWhiteSpace(ids) && string.IsNullOrWhiteSpace(status))
{
Console.WriteLine("Error: at least one filter (--ids, --status) is required.");
return;
}
using var db = new Database(dbPath);
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, null, status);
var setClauses = new List<string> { "status = @newStatus" };
var updateParams = new List<(string, object?)> { ("@newStatus", newStatus) };
if (notes is not null)
{
setClauses.Add("notes = @newNotes");
updateParams.Add(("@newNotes", notes));
}
BatchFilters.PreviewOrExecute(db, "modules",
"id, name, status, notes",
string.Join(", ", setClauses), updateParams,
whereClause, filterParams, execute);
});
return cmd;
}
private static Command CreateBatchMap(Option<string> dbOption)
{
var cmd = new Command("batch-map", "Bulk map modules to .NET projects");
var idsOpt = BatchFilters.IdsOption();
var statusOpt = BatchFilters.StatusOption();
var executeOpt = BatchFilters.ExecuteOption();
var setProject = new Option<string?>("--set-project") { Description = ".NET project" };
var setNamespace = new Option<string?>("--set-namespace") { Description = ".NET namespace" };
var setClass = new Option<string?>("--set-class") { Description = ".NET class" };
cmd.Add(idsOpt);
cmd.Add(statusOpt);
cmd.Add(executeOpt);
cmd.Add(setProject);
cmd.Add(setNamespace);
cmd.Add(setClass);
cmd.SetAction(parseResult =>
{
var dbPath = parseResult.GetValue(dbOption)!;
var ids = parseResult.GetValue(idsOpt);
var status = parseResult.GetValue(statusOpt);
var execute = parseResult.GetValue(executeOpt);
var project = parseResult.GetValue(setProject);
var ns = parseResult.GetValue(setNamespace);
var cls = parseResult.GetValue(setClass);
if (string.IsNullOrWhiteSpace(ids) && string.IsNullOrWhiteSpace(status))
{
Console.WriteLine("Error: at least one filter (--ids, --status) is required.");
return;
}
if (project is null && ns is null && cls is null)
{
Console.WriteLine("Error: at least one of --set-project, --set-namespace, --set-class is required.");
return;
}
using var db = new Database(dbPath);
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, null, status);
var setClauses = new List<string>();
var updateParams = new List<(string, object?)>();
if (project is not null) { setClauses.Add("dotnet_project = @setProject"); updateParams.Add(("@setProject", project)); }
if (ns is not null) { setClauses.Add("dotnet_namespace = @setNamespace"); updateParams.Add(("@setNamespace", ns)); }
if (cls is not null) { setClauses.Add("dotnet_class = @setClass"); updateParams.Add(("@setClass", cls)); }
BatchFilters.PreviewOrExecute(db, "modules",
"id, name, status, dotnet_project, dotnet_namespace, dotnet_class",
string.Join(", ", setClauses), updateParams,
whereClause, filterParams, execute);
});
return cmd;
}
```
**Step 2: Verify it compiles**
Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
Expected: Build succeeded.
**Step 3: Commit**
```bash
git add tools/NatsNet.PortTracker/Commands/ModuleCommands.cs
git commit -m "feat(porttracker): add module batch-update and batch-map commands"
```
---
### Task 5: Add batch commands to LibraryCommands
**Files:**
- Modify: `tools/NatsNet.PortTracker/Commands/LibraryCommands.cs:86-91`
**Step 1: Add batch-update and batch-map subcommands**
In `LibraryCommands.cs`, replace lines 86-91 with:
```csharp
libraryCommand.Add(listCmd);
libraryCommand.Add(mapCmd);
libraryCommand.Add(suggestCmd);
libraryCommand.Add(CreateBatchUpdate(dbOption));
libraryCommand.Add(CreateBatchMap(dbOption));
return libraryCommand;
}
```
Then add these two static methods before the `Truncate` method:
```csharp
private static Command CreateBatchUpdate(Option<string> dbOption)
{
var cmd = new Command("batch-update", "Bulk update library status");
var idsOpt = BatchFilters.IdsOption();
var statusOpt = BatchFilters.StatusOption();
var executeOpt = BatchFilters.ExecuteOption();
var setStatus = new Option<string>("--set-status") { Description = "New status to set", Required = true };
var setNotes = new Option<string?>("--set-notes") { Description = "Usage notes to set" };
cmd.Add(idsOpt);
cmd.Add(statusOpt);
cmd.Add(executeOpt);
cmd.Add(setStatus);
cmd.Add(setNotes);
cmd.SetAction(parseResult =>
{
var dbPath = parseResult.GetValue(dbOption)!;
var ids = parseResult.GetValue(idsOpt);
var status = parseResult.GetValue(statusOpt);
var execute = parseResult.GetValue(executeOpt);
var newStatus = parseResult.GetValue(setStatus)!;
var notes = parseResult.GetValue(setNotes);
if (string.IsNullOrWhiteSpace(ids) && string.IsNullOrWhiteSpace(status))
{
Console.WriteLine("Error: at least one filter (--ids, --status) is required.");
return;
}
using var db = new Database(dbPath);
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, null, status);
var setClauses = new List<string> { "status = @newStatus" };
var updateParams = new List<(string, object?)> { ("@newStatus", newStatus) };
if (notes is not null)
{
setClauses.Add("dotnet_usage_notes = @newNotes");
updateParams.Add(("@newNotes", notes));
}
BatchFilters.PreviewOrExecute(db, "library_mappings",
"id, go_import_path, status, dotnet_usage_notes",
string.Join(", ", setClauses), updateParams,
whereClause, filterParams, execute);
});
return cmd;
}
private static Command CreateBatchMap(Option<string> dbOption)
{
var cmd = new Command("batch-map", "Bulk map libraries to .NET packages");
var idsOpt = BatchFilters.IdsOption();
var statusOpt = BatchFilters.StatusOption();
var executeOpt = BatchFilters.ExecuteOption();
var setPackage = new Option<string?>("--set-package") { Description = ".NET NuGet package" };
var setNamespace = new Option<string?>("--set-namespace") { Description = ".NET namespace" };
var setNotes = new Option<string?>("--set-notes") { Description = "Usage notes" };
cmd.Add(idsOpt);
cmd.Add(statusOpt);
cmd.Add(executeOpt);
cmd.Add(setPackage);
cmd.Add(setNamespace);
cmd.Add(setNotes);
cmd.SetAction(parseResult =>
{
var dbPath = parseResult.GetValue(dbOption)!;
var ids = parseResult.GetValue(idsOpt);
var status = parseResult.GetValue(statusOpt);
var execute = parseResult.GetValue(executeOpt);
var package = parseResult.GetValue(setPackage);
var ns = parseResult.GetValue(setNamespace);
var notes = parseResult.GetValue(setNotes);
if (string.IsNullOrWhiteSpace(ids) && string.IsNullOrWhiteSpace(status))
{
Console.WriteLine("Error: at least one filter (--ids, --status) is required.");
return;
}
if (package is null && ns is null && notes is null)
{
Console.WriteLine("Error: at least one of --set-package, --set-namespace, --set-notes is required.");
return;
}
using var db = new Database(dbPath);
var (whereClause, filterParams) = BatchFilters.BuildWhereClause(ids, null, status);
var setClauses = new List<string>();
var updateParams = new List<(string, object?)>();
if (package is not null) { setClauses.Add("dotnet_package = @setPackage"); updateParams.Add(("@setPackage", package)); }
if (ns is not null) { setClauses.Add("dotnet_namespace = @setNamespace"); updateParams.Add(("@setNamespace", ns)); }
if (notes is not null) { setClauses.Add("dotnet_usage_notes = @setNotes"); updateParams.Add(("@setNotes", notes)); }
BatchFilters.PreviewOrExecute(db, "library_mappings",
"id, go_import_path, status, dotnet_package, dotnet_namespace",
string.Join(", ", setClauses), updateParams,
whereClause, filterParams, execute);
});
return cmd;
}
```
**Step 2: Verify it compiles**
Run: `dotnet build tools/NatsNet.PortTracker/NatsNet.PortTracker.csproj`
Expected: Build succeeded.
**Step 3: Commit**
```bash
git add tools/NatsNet.PortTracker/Commands/LibraryCommands.cs
git commit -m "feat(porttracker): add library batch-update and batch-map commands"
```
---
### Task 6: End-to-end smoke test
**Files:** None — testing only.
**Step 1: Test feature batch-update dry-run**
Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --status deferred --set-status deferred --db porting.db`
Expected: Preview showing deferred features.
**Step 2: Test test batch-update dry-run**
Run: `dotnet run --project tools/NatsNet.PortTracker -- test batch-update --ids 1-5 --set-status verified --db porting.db`
Expected: Preview showing tests 1-5.
**Step 3: Test module batch-update dry-run**
Run: `dotnet run --project tools/NatsNet.PortTracker -- module batch-update --status verified --set-status verified --db porting.db`
Expected: Preview showing verified modules.
**Step 4: Test library batch-map dry-run**
Run: `dotnet run --project tools/NatsNet.PortTracker -- library batch-map --status mapped --set-package "test" --db porting.db`
Expected: Preview showing mapped libraries.
**Step 5: Test error cases**
Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --set-status deferred --db porting.db`
Expected: "Error: at least one filter (--ids, --module, --status) is required."
Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-map --ids 1-5 --db porting.db`
Expected: "Error: at least one of --set-project, --set-class, --set-method is required."
**Step 6: Test help output**
Run: `dotnet run --project tools/NatsNet.PortTracker -- feature batch-update --help`
Expected: Shows all options with descriptions.
**Step 7: Final commit**
No code changes — this task is verification only. If any issues found, fix and commit with appropriate message.

View File

@@ -0,0 +1,13 @@
{
"planPath": "docs/plans/2026-02-27-porttracker-batch-plan.md",
"tasks": [
{"id": 0, "nativeId": 7, "subject": "Task 0: Add ExecuteInTransaction to Database", "status": "pending"},
{"id": 1, "nativeId": 8, "subject": "Task 1: Create BatchFilters shared infrastructure", "status": "pending", "blockedBy": [0]},
{"id": 2, "nativeId": 9, "subject": "Task 2: Add batch commands to FeatureCommands", "status": "pending", "blockedBy": [1]},
{"id": 3, "nativeId": 10, "subject": "Task 3: Add batch commands to TestCommands", "status": "pending", "blockedBy": [1]},
{"id": 4, "nativeId": 11, "subject": "Task 4: Add batch commands to ModuleCommands", "status": "pending", "blockedBy": [1]},
{"id": 5, "nativeId": 12, "subject": "Task 5: Add batch commands to LibraryCommands", "status": "pending", "blockedBy": [1]},
{"id": 6, "nativeId": 13, "subject": "Task 6: End-to-end smoke test", "status": "pending", "blockedBy": [2, 3, 4, 5]}
],
"lastUpdated": "2026-02-27T00:00:00Z"
}

View File

@@ -0,0 +1,63 @@
# Unit Test Audit Extension Design
## Goal
Extend the PortTracker `audit` command to classify unit tests (not just features) by inspecting .NET test source code with Roslyn.
## Architecture
Parameterize the existing audit pipeline (`AuditCommand` + `SourceIndexer` + `FeatureClassifier`) to support both `features` and `unit_tests` tables. No new files — the same indexer and classifier logic applies to test methods.
## CLI Interface
```
dotnet run -- audit --type features|tests|all [--source <path>] [--module <id>] [--execute]
```
| Flag | Default (features) | Default (tests) |
|------|-------------------|-----------------|
| `--type` | `features` | — |
| `--source` | `dotnet/src/ZB.MOM.NatsNet.Server` | `dotnet/tests/ZB.MOM.NatsNet.Server.Tests` |
| `--output` | `reports/audit-results.csv` | `reports/audit-results-tests.csv` |
- `--type all` runs both sequentially.
- `--source` override works for either type.
## Changes Required
### AuditCommand.cs
1. Add `--type` option with values `features`, `tests`, `all`.
2. Thread an `AuditTarget` (table name + default source + default output + display label) through `RunAudit` and `ApplyUpdates`.
3. `--type all` calls `RunAudit` twice with different targets.
4. `ApplyUpdates` uses the target's table name in UPDATE SQL.
### FeatureClassifier.cs
No changes. Same N/A lookup and classification logic applies to unit tests.
### SourceIndexer.cs
No changes. Already generic — just pass a different directory path.
## Pre-audit DB Reset
Before running the test audit, manually reset deferred tests to `unknown`:
```sql
sqlite3 porting.db "UPDATE unit_tests SET status = 'unknown' WHERE status = 'deferred';"
```
## Execution Sequence
1. Reset deferred tests: `sqlite3 porting.db "UPDATE unit_tests SET status = 'unknown' WHERE status = 'deferred';"`
2. Run audit: `dotnet run -- audit --type tests --db porting.db --execute`
3. Verify results and generate report.
## Classification Behavior for Tests
Same priority as features:
1. **N/A**: Go method matches logging/signal patterns → `n_a`
2. **Method found**: Test class + method exists in test project → `verified` or `stub`
3. **Class exists, method missing**: → `deferred` ("method not found")
4. **Class not found**: → `deferred` ("class not found")

View File

@@ -0,0 +1,328 @@
# Phase 1: Go Codebase Decomposition
## Objective
Parse the Go NATS server source code into a structured SQLite database, extracting
modules, features (functions/methods), unit tests, external library imports, and
inter-module dependencies. This database becomes the single source of truth that
drives all subsequent porting phases.
## Prerequisites
| Requirement | Version / Notes |
|---|---|
| Go | 1.25+ (required by `tools/go-analyzer/go.mod`) |
| .NET SDK | 10.0+ |
| SQLite3 CLI | 3.x (optional, for manual inspection) |
| CGO | Must be enabled (`CGO_ENABLED=1`); the Go analyzer uses `github.com/mattn/go-sqlite3` |
| Go source | Cloned at `golang/nats-server/` relative to the repo root |
Verify prerequisites before starting:
```bash
go version # should print go1.25 or later
dotnet --version # should print 10.x
sqlite3 --version # optional, any 3.x
echo $CGO_ENABLED # should print 1 (or be unset; we set it explicitly below)
```
## Source and Target Locations
| Component | Path |
|---|---|
| Go source code | `golang/` (specifically `golang/nats-server/`) |
| .NET ported version | `dotnet/` |
## Milestone Tracking
This phase corresponds to **Milestone 1** in [Gitea](https://gitea.dohertylan.com/dohertj2/natsnet/milestone/1). When starting this phase, verify the milestone is open. Assign relevant issues to this milestone as work progresses.
### Issue Updates
Each completion criterion has a corresponding Gitea issue. Update issues as you work:
- **Starting a criterion**: Add a comment noting work has begun
- **Blocked**: Add a comment describing the blocker
- **Complete**: Close the issue with a comment summarizing the result (e.g., counts, verification output)
Close issues via CLI:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/issues/{N}" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close via the Gitea web UI.
## Steps
### Step 1: Initialize the porting database
Create a fresh SQLite database with the porting tracker schema. This creates
`porting.db` in the repository root with all tables, indexes, and triggers.
```bash
dotnet run --project tools/NatsNet.PortTracker -- init --db porting.db --schema porting-schema.sql
```
Expected output:
```
Database initialized at porting.db
```
If the database already exists, this command is idempotent -- it applies
`CREATE TABLE IF NOT EXISTS` statements and will not destroy existing data.
### Step 2: Build the Go analyzer
The Go analyzer is a standalone tool that uses `go/ast` to parse Go source files
and writes results directly into the SQLite database.
```bash
cd tools/go-analyzer && CGO_ENABLED=1 go build -o go-analyzer . && cd ../..
```
This produces the binary `tools/go-analyzer/go-analyzer`. If the build fails,
see the Troubleshooting section below.
### Step 3: Run the Go analyzer
Point the analyzer at the NATS server source and the porting database:
```bash
./tools/go-analyzer/go-analyzer \
--source golang/nats-server \
--db porting.db \
--schema porting-schema.sql
```
Expected output (counts will vary with the NATS server version):
```
Analysis complete:
Modules: <N>
Features: <N>
Unit Tests: <N>
Dependencies: <N>
Imports: <N>
```
The analyzer does the following:
1. Walks `golang/nats-server/server/` for all `.go` files (skipping `configs/`
and `testdata/` directories).
2. Groups files into logical modules by directory.
3. Parses each non-test file, extracting every `func` and method as a feature.
4. Parses each `_test.go` file, extracting `Test*` and `Benchmark*` functions.
5. Infers module-level dependencies from cross-package imports.
6. Collects all import paths and classifies them as stdlib or external.
7. Writes modules, features, unit_tests, dependencies, and library_mappings to
the database.
### Step 4: Review module groupings
List all modules that were created to confirm the grouping makes sense:
```bash
dotnet run --project tools/NatsNet.PortTracker -- module list --db porting.db
```
This shows every module with its ID, name, status, Go package, and line count.
Verify that the major areas of the NATS server are represented (e.g., core,
jetstream, client, auth, protocol, route, gateway, leafnode, mqtt, websocket,
monitoring, logging, errors, subscriptions, tls, events, raft, config, accounts).
### Step 5: Spot-check individual modules
Inspect a few modules to verify that features and tests were correctly extracted:
```bash
# Check the first module
dotnet run --project tools/NatsNet.PortTracker -- module show 1 --db porting.db
# Check a few more
dotnet run --project tools/NatsNet.PortTracker -- module show 2 --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- module show 3 --db porting.db
```
For each module, confirm that:
- Features list functions and methods from the corresponding Go files.
- Each feature has a `go_file`, `go_method`, and `go_line_number`.
- Tests are listed under the module and have `go_file` and `go_method` populated.
- Dependencies point to other valid modules.
### Step 6: Verify test extraction
List all extracted tests to confirm test files were parsed:
```bash
dotnet run --project tools/NatsNet.PortTracker -- test list --db porting.db
```
Spot-check a few individual tests for detail:
```bash
dotnet run --project tools/NatsNet.PortTracker -- test show 1 --db porting.db
```
Verify that each test has a module assignment and that the `feature_id` link is
populated where the analyzer could infer the connection from naming conventions
(e.g., `TestConnect` links to a feature named `Connect`).
### Step 7: Review inter-module dependencies
Check the dependency graph for a representative module:
```bash
dotnet run --project tools/NatsNet.PortTracker -- dependency show module 1 --db porting.db
```
Also view the full blocked-items report to see the overall dependency shape:
```bash
dotnet run --project tools/NatsNet.PortTracker -- dependency blocked --db porting.db
```
And check which items have no unported dependencies and are ready to start:
```bash
dotnet run --project tools/NatsNet.PortTracker -- dependency ready --db porting.db
```
### Step 8: Review extracted library mappings
List all external Go libraries that were detected:
```bash
dotnet run --project tools/NatsNet.PortTracker -- library list --db porting.db
```
All entries should have status `not_mapped` at this point. Library mapping is
handled in Phase 3.
### Step 9: Generate a baseline summary report
Create a summary snapshot to use as a reference for Phase 2 verification:
```bash
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
```
Optionally export a full markdown report for archival:
```bash
dotnet run --project tools/NatsNet.PortTracker -- report export --format md --output docs/reports/phase-1-baseline.md --db porting.db
```
## Completion Criteria
Phase 1 is complete when ALL of the following are true:
- [ ] [#1](https://gitea.dohertylan.com/dohertj2/natsnet/issues/1) `porting.db` exists and contains data in all five tables (modules, features,
unit_tests, dependencies, library_mappings).
- [ ] [#2](https://gitea.dohertylan.com/dohertj2/natsnet/issues/2) All Go source files under `golang/nats-server/server/` are accounted for
(no files silently skipped without a logged warning).
- [ ] [#3](https://gitea.dohertylan.com/dohertj2/natsnet/issues/3) All public and private functions/methods are extracted as features.
- [ ] [#4](https://gitea.dohertylan.com/dohertj2/natsnet/issues/4) All `Test*` and `Benchmark*` functions are extracted as unit_tests.
- [ ] [#5](https://gitea.dohertylan.com/dohertj2/natsnet/issues/5) Test-to-feature links are populated where naming conventions allow inference.
- [ ] [#6](https://gitea.dohertylan.com/dohertj2/natsnet/issues/6) Module-level dependencies are recorded in the dependencies table.
- [ ] [#7](https://gitea.dohertylan.com/dohertj2/natsnet/issues/7) External import paths are recorded in the library_mappings table.
- [ ] [#8](https://gitea.dohertylan.com/dohertj2/natsnet/issues/8) `phase check 1` shows all checklist items marked `[x]`:
```bash
dotnet run --project tools/NatsNet.PortTracker -- phase check 1 --db porting.db
```
Expected:
```
Phase 1: Analysis & Schema
Run Go AST analyzer, populate DB schema, map libraries
Phase 1 Checklist:
[x] Modules populated: <N>
[x] Features populated: <N>
[x] Unit tests populated: <N>
[x] Dependencies mapped: <N>
[x] Libraries identified: <N>
[ ] All libraries mapped: 0/<N>
```
Note: The "All libraries mapped" item will be unchecked -- that is expected.
Library mapping is the concern of Phase 3.
- [ ] Close the Phase 1 milestone in Gitea:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/milestones/1" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close it via the Gitea web UI at https://gitea.dohertylan.com/dohertj2/natsnet/milestone/1
## Troubleshooting
### CGO_ENABLED not set or build fails with "gcc not found"
The `go-sqlite3` driver requires C compilation. Make sure you have a C compiler
installed and CGO is enabled:
```bash
# macOS -- Xcode command line tools
xcode-select --install
# Then build with explicit CGO
CGO_ENABLED=1 go build -o go-analyzer .
```
### "cannot find package" errors during Go build
The analyzer depends on `github.com/mattn/go-sqlite3`. Run:
```bash
cd tools/go-analyzer
go mod download
go mod verify
```
### Wrong source path
The `--source` flag must point to the root of the cloned nats-server repository
(the directory that contains the `server/` subdirectory). If you see
"discovering files: no such file or directory", verify:
```bash
ls golang/nats-server/server/
```
### Database locked errors
If you run the analyzer while another process has `porting.db` open, SQLite may
report a lock error. Close any other connections (including `sqlite3` CLI
sessions) and retry. The schema enables WAL mode to reduce lock contention:
```sql
PRAGMA journal_mode=WAL;
```
### Analyzer prints warnings but continues
Warnings like "Warning: skipping server/foo.go: <parse error>" mean an individual
file could not be parsed. The analyzer continues with remaining files. Investigate
any warnings -- they may indicate a Go version mismatch or syntax not yet
supported by the `go/ast` parser at your Go version.
### Empty database after analyzer runs
If the analyzer prints zeros for all counts, verify that:
1. The `--source` path is correct and contains Go files.
2. The `--schema` path points to a valid `porting-schema.sql`.
3. The `--db` path is writable.
You can inspect the database directly:
```bash
sqlite3 porting.db "SELECT COUNT(*) FROM modules;"
sqlite3 porting.db "SELECT COUNT(*) FROM features;"
sqlite3 porting.db "SELECT COUNT(*) FROM unit_tests;"
```

View File

@@ -0,0 +1,351 @@
# Phase 2: Verification of Captured Items
## Objective
Verify that the Phase 1 decomposition captured every Go source file, function,
test, and dependency accurately. Compare database counts against independent
baselines derived directly from the filesystem. Identify and fix any gaps before
proceeding to library mapping and porting.
## Prerequisites
- Phase 1 is complete (`porting.db` is populated).
- The Go source at `golang/nats-server/` has not changed since the Phase 1
analyzer run. If the source was updated, re-run the Phase 1 analyzer first.
- `dotnet`, `sqlite3`, `find`, `grep`, and `wc` are available on your PATH.
## Source and Target Locations
| Component | Path |
|---|---|
| Go source code | `golang/` (specifically `golang/nats-server/`) |
| .NET ported version | `dotnet/` |
## Milestone Tracking
This phase corresponds to **Milestone 2** in [Gitea](https://gitea.dohertylan.com/dohertj2/natsnet/milestone/2). When starting this phase, verify the milestone is open. Assign relevant issues to this milestone as work progresses.
### Issue Updates
Each completion criterion has a corresponding Gitea issue. Update issues as you work:
- **Starting a criterion**: Add a comment noting work has begun
- **Blocked**: Add a comment describing the blocker
- **Complete**: Close the issue with a comment summarizing the result (e.g., counts, verification output)
Close issues via CLI:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/issues/{N}" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close via the Gitea web UI.
## Steps
### Step 1: Generate the summary report
Start with a high-level view of what the database contains:
```bash
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
```
Record the counts for modules, features, unit tests, and library mappings. These
are the numbers you will verify in subsequent steps.
### Step 2: Count Go source files on disk
Count non-test `.go` files under the server directory (the scope of the analyzer):
```bash
find golang/nats-server/server -name "*.go" ! -name "*_test.go" ! -path "*/configs/*" ! -path "*/testdata/*" | wc -l
```
This should produce approximately 109 files. Compare this count against the
number of distinct `go_file` values in the features table:
```bash
sqlite3 porting.db "SELECT COUNT(DISTINCT go_file) FROM features;"
```
If the database count is lower, some source files may have been skipped. Check
the analyzer stderr output for warnings, or list the missing files:
```bash
sqlite3 porting.db "SELECT DISTINCT go_file FROM features ORDER BY go_file;" > /tmp/db_files.txt
find golang/nats-server/server -name "*.go" ! -name "*_test.go" ! -path "*/configs/*" ! -path "*/testdata/*" -exec realpath --relative-to=golang/nats-server {} \; | sort > /tmp/disk_files.txt
diff /tmp/db_files.txt /tmp/disk_files.txt
```
### Step 3: Count Go test files on disk
```bash
find golang/nats-server/server -name "*_test.go" ! -path "*/configs/*" ! -path "*/testdata/*" | wc -l
```
This should produce approximately 85 files. Compare against distinct test files
in the database:
```bash
sqlite3 porting.db "SELECT COUNT(DISTINCT go_file) FROM unit_tests;"
```
### Step 4: Compare function counts
Count all exported and unexported functions in source files on disk:
```bash
grep -r "^func " golang/nats-server/server/ --include="*.go" --exclude="*_test.go" | grep -v "/configs/" | grep -v "/testdata/" | wc -l
```
Compare against the features count from the database:
```bash
sqlite3 porting.db "SELECT COUNT(*) FROM features;"
```
The numbers should be close. Small discrepancies can occur because:
- The `grep` approach counts lines starting with `func` which may miss functions
with preceding comments on the same line or multi-line signatures.
- The AST parser used by the analyzer is more accurate; it finds all `func`
declarations regardless of formatting.
If the database count is significantly lower (more than 5% off), investigate.
### Step 5: Compare test function counts
Count test functions on disk:
```bash
grep -r "^func Test" golang/nats-server/server/ --include="*_test.go" | wc -l
```
Also count benchmarks:
```bash
grep -r "^func Benchmark" golang/nats-server/server/ --include="*_test.go" | wc -l
```
Compare the combined total against the unit_tests table:
```bash
sqlite3 porting.db "SELECT COUNT(*) FROM unit_tests;"
```
### Step 6: Run the phase check command
The PortTracker has a built-in Phase 1 checklist that verifies all tables are
populated:
```bash
dotnet run --project tools/NatsNet.PortTracker -- phase check 1 --db porting.db
```
All items except "All libraries mapped" should show `[x]`.
### Step 7: Check for orphaned items
Look for features that are not linked to any module (should be zero):
```bash
sqlite3 porting.db "SELECT COUNT(*) FROM features WHERE module_id NOT IN (SELECT id FROM modules);"
```
Look for tests that are not linked to any module (should be zero):
```bash
sqlite3 porting.db "SELECT COUNT(*) FROM unit_tests WHERE module_id NOT IN (SELECT id FROM modules);"
```
Look for test-to-feature links that point to non-existent features:
```bash
sqlite3 porting.db "SELECT COUNT(*) FROM unit_tests WHERE feature_id IS NOT NULL AND feature_id NOT IN (SELECT id FROM features);"
```
Look for dependencies that reference non-existent source or target items:
```bash
sqlite3 porting.db "
SELECT COUNT(*) FROM dependencies
WHERE (source_type = 'module' AND source_id NOT IN (SELECT id FROM modules))
OR (target_type = 'module' AND target_id NOT IN (SELECT id FROM modules))
OR (source_type = 'feature' AND source_id NOT IN (SELECT id FROM features))
OR (target_type = 'feature' AND target_id NOT IN (SELECT id FROM features))
OR (source_type = 'unit_test' AND source_id NOT IN (SELECT id FROM unit_tests))
OR (target_type = 'unit_test' AND target_id NOT IN (SELECT id FROM unit_tests));
"
```
All of these queries should return 0.
### Step 8: Review the largest modules
The largest modules are the most likely to have issues. List modules sorted by
feature count:
```bash
sqlite3 porting.db "
SELECT m.id, m.name, m.go_line_count,
COUNT(f.id) as feature_count
FROM modules m
LEFT JOIN features f ON f.module_id = m.id
GROUP BY m.id
ORDER BY feature_count DESC
LIMIT 10;
"
```
For each of the top 3 modules, do a manual spot-check:
```bash
dotnet run --project tools/NatsNet.PortTracker -- module show <id> --db porting.db
```
Scroll through the features list and verify that the functions look correct
(check a few against the actual Go source file).
### Step 9: Validate the dependency graph
Check for any circular module dependencies (modules that depend on each other):
```bash
sqlite3 porting.db "
SELECT d1.source_id, d1.target_id
FROM dependencies d1
JOIN dependencies d2
ON d1.source_type = d2.target_type AND d1.source_id = d2.target_id
AND d1.target_type = d2.source_type AND d1.target_id = d2.source_id
WHERE d1.source_type = 'module' AND d1.target_type = 'module';
"
```
Circular dependencies are not necessarily wrong (Go packages can have them via
interfaces), but they should be reviewed.
Check which items are blocked by unported dependencies:
```bash
dotnet run --project tools/NatsNet.PortTracker -- dependency blocked --db porting.db
```
And confirm that at least some items are ready to port (have no unported deps):
```bash
dotnet run --project tools/NatsNet.PortTracker -- dependency ready --db porting.db
```
### Step 10: Verify library import completeness
Ensure every external import found in the source is tracked:
```bash
sqlite3 porting.db "SELECT COUNT(*) FROM library_mappings;"
```
Cross-check against a manual count of unique non-stdlib imports:
```bash
grep -rh "\"" golang/nats-server/server/ --include="*.go" | \
grep -oP '"\K[^"]+' | \
grep '\.' | \
sort -u | \
wc -l
```
This is an approximate check. The AST-based analyzer is more accurate than grep
for import extraction, but the numbers should be in the same ballpark.
### Step 11: Export a verification snapshot
Save the current state as a markdown report for your records:
```bash
dotnet run --project tools/NatsNet.PortTracker -- report export \
--format md \
--output docs/reports/phase-2-verification.md \
--db porting.db
```
## Completion Criteria
Phase 2 is complete when ALL of the following are true:
- [ ] [#9](https://gitea.dohertylan.com/dohertj2/natsnet/issues/9) Source file counts on disk match distinct `go_file` counts in the database
(within a small margin for intentionally excluded directories).
- [ ] [#10](https://gitea.dohertylan.com/dohertj2/natsnet/issues/10) Feature counts from `grep` are within 5% of the database count (AST is the
authoritative source).
- [ ] [#11](https://gitea.dohertylan.com/dohertj2/natsnet/issues/11) Test function counts from `grep` match the database count closely.
- [ ] [#12](https://gitea.dohertylan.com/dohertj2/natsnet/issues/12) No orphaned features (all linked to valid modules).
- [ ] [#13](https://gitea.dohertylan.com/dohertj2/natsnet/issues/13) No orphaned tests (all linked to valid modules).
- [ ] [#14](https://gitea.dohertylan.com/dohertj2/natsnet/issues/14) No broken test-to-feature links.
- [ ] [#15](https://gitea.dohertylan.com/dohertj2/natsnet/issues/15) No dangling dependency references.
- [ ] [#16](https://gitea.dohertylan.com/dohertj2/natsnet/issues/16) Dependency graph is reviewed -- circular deps (if any) are acknowledged.
- [ ] [#17](https://gitea.dohertylan.com/dohertj2/natsnet/issues/17) `dependency ready` returns at least one item (the graph has valid roots).
- [ ] [#18](https://gitea.dohertylan.com/dohertj2/natsnet/issues/18) Library mappings table contains all external imports.
- [ ] [#19](https://gitea.dohertylan.com/dohertj2/natsnet/issues/19) `phase check 1` passes with all items except "All libraries mapped" checked.
- [ ] Close the Phase 2 milestone in Gitea:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/milestones/2" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close it via the Gitea web UI at https://gitea.dohertylan.com/dohertj2/natsnet/milestone/2
## Troubleshooting
### File count mismatch is large
If the disk file count exceeds the database count by more than a few files,
re-run the analyzer with stderr visible:
```bash
./tools/go-analyzer/go-analyzer \
--source golang/nats-server \
--db porting.db \
--schema porting-schema.sql 2>&1 | tee /tmp/analyzer.log
```
Search for warnings:
```bash
grep "Warning" /tmp/analyzer.log
```
Common causes:
- Files with build tags that prevent parsing (e.g., `//go:build ignore`).
- Files in excluded directories (`configs/`, `testdata/`).
- Syntax errors in Go files that the parser cannot handle.
### Feature count is significantly different
The AST parser counts every `func` declaration, including unexported helper
functions. The `grep` baseline only matches lines starting with `func `. If
features that have multiline signatures like:
```go
func (s *Server) handleConnection(
conn net.Conn,
) {
```
...they will be missed by grep but found by the AST parser. Trust the database
count as authoritative.
### Orphaned records found
If orphaned records exist, the analyzer may have a bug or the database was
partially populated from a prior run. The safest fix is to:
1. Delete the database: `rm porting.db`
2. Re-run Phase 1 from Step 1.
### Tests not linked to features
The analyzer uses naming conventions to link tests to features (e.g.,
`TestConnect` maps to a feature containing `Connect`). If many tests show
`feature_id = NULL`, this is expected for tests whose names do not follow the
convention. These links can be manually added later if needed.

View File

@@ -0,0 +1,319 @@
# Phase 3: Library Mapping
## Objective
Map every external Go dependency detected by the analyzer to its .NET equivalent.
This includes Go standard library packages, well-known third-party libraries, and
NATS ecosystem packages. When Phase 3 is complete, the `library suggest` command
returns an empty list and every import has a documented .NET migration path.
## Prerequisites
- Phases 1 and 2 are complete (database is populated and verified).
- Familiarity with both the Go standard library and .NET BCL / NuGet ecosystem.
- A working `dotnet` CLI for running PortTracker commands.
## Source and Target Locations
| Component | Path |
|---|---|
| Go source code | `golang/` (specifically `golang/nats-server/`) |
| .NET ported version | `dotnet/` |
## Milestone Tracking
This phase corresponds to **Milestone 3** in [Gitea](https://gitea.dohertylan.com/dohertj2/natsnet/milestone/3). When starting this phase, verify the milestone is open. Assign relevant issues to this milestone as work progresses.
### Issue Updates
Each completion criterion has a corresponding Gitea issue. Update issues as you work:
- **Starting a criterion**: Add a comment noting work has begun
- **Blocked**: Add a comment describing the blocker
- **Complete**: Close the issue with a comment summarizing the result (e.g., counts, verification output)
Close issues via CLI:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/issues/{N}" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close via the Gitea web UI.
## Steps
### Step 1: List all unmapped libraries
Start by seeing everything that needs attention:
```bash
dotnet run --project tools/NatsNet.PortTracker -- library suggest --db porting.db
```
This shows all library_mappings entries with status `not_mapped`, sorted by import
path. The output includes the Go import path, the library name, and a usage
description.
If the list is empty, all libraries are already mapped and Phase 3 is complete.
### Step 2: Review the full library list
To see both mapped and unmapped libraries in one view:
```bash
dotnet run --project tools/NatsNet.PortTracker -- library list --db porting.db
```
You can also filter by status:
```bash
dotnet run --project tools/NatsNet.PortTracker -- library list --status not_mapped --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- library list --status mapped --db porting.db
```
### Step 3: Map each library
For each unmapped library, determine the appropriate .NET equivalent using the
reference table below, then record the mapping:
```bash
dotnet run --project tools/NatsNet.PortTracker -- library map <id> \
--package "<NuGet package or BCL>" \
--namespace "<.NET namespace>" \
--notes "<migration notes>" \
--db porting.db
```
**Example -- mapping `encoding/json`:**
```bash
dotnet run --project tools/NatsNet.PortTracker -- library map 1 \
--package "System.Text.Json" \
--namespace "System.Text.Json" \
--notes "Use JsonSerializer. Consider source generators for AOT." \
--db porting.db
```
**Example -- mapping `github.com/klauspost/compress`:**
```bash
dotnet run --project tools/NatsNet.PortTracker -- library map 12 \
--package "System.IO.Compression" \
--namespace "System.IO.Compression" \
--notes "S2/Snappy codec needs evaluation; may need custom impl or IronSnappy NuGet." \
--db porting.db
```
Repeat for every entry in the `library suggest` output.
### Step 4: Handle libraries that need custom implementations
Some Go libraries have no direct .NET equivalent and will require custom code.
For these, record the mapping with a descriptive note:
```bash
dotnet run --project tools/NatsNet.PortTracker -- library map <id> \
--package "Custom" \
--namespace "NatsNet.Internal" \
--notes "No direct equivalent; requires custom implementation. See <details>." \
--db porting.db
```
### Step 5: Verify all libraries are mapped
Run the suggest command again -- it should return an empty list:
```bash
dotnet run --project tools/NatsNet.PortTracker -- library suggest --db porting.db
```
Expected output:
```
All libraries have been mapped!
```
Also verify via the full list:
```bash
dotnet run --project tools/NatsNet.PortTracker -- library list --db porting.db
```
Every entry should show status `mapped` (or `verified` if you have already
validated the mapping in code).
### Step 6: Run the phase check
Confirm Phase 1 now shows full completion including library mappings:
```bash
dotnet run --project tools/NatsNet.PortTracker -- phase check 1 --db porting.db
```
Expected:
```
Phase 1 Checklist:
[x] Modules populated: <N>
[x] Features populated: <N>
[x] Unit tests populated: <N>
[x] Dependencies mapped: <N>
[x] Libraries identified: <N>
[x] All libraries mapped: <N>/<N>
```
### Step 7: Export the mapping report
Save the complete library mapping state for reference during porting:
```bash
dotnet run --project tools/NatsNet.PortTracker -- report export \
--format md \
--output docs/reports/phase-3-library-mapping.md \
--db porting.db
```
## Common Go to .NET Mappings Reference
Use this table as a starting point when determining .NET equivalents. Adapt based
on the specific usage patterns found in the NATS server source.
### Go Standard Library
| Go Package | .NET Equivalent | Notes |
|---|---|---|
| `encoding/json` | `System.Text.Json` | Use `JsonSerializer`; consider source generators for performance |
| `encoding/binary` | `System.Buffers.Binary.BinaryPrimitives` | For endian-aware reads/writes |
| `encoding/base64` | `System.Convert` | `Convert.ToBase64String` / `Convert.FromBase64String` |
| `encoding/hex` | `System.Convert` | `Convert.ToHexString` (.NET 5+) |
| `encoding/pem` | `System.Security.Cryptography.PemEncoding` | .NET 5+ |
| `sync` | `System.Threading` | `Mutex` -> `lock` / `Monitor`; `RWMutex` -> `ReaderWriterLockSlim`; `WaitGroup` -> `CountdownEvent`; `Once` -> `Lazy<T>` |
| `sync/atomic` | `System.Threading.Interlocked` | `Interlocked.Increment`, `CompareExchange`, etc. |
| `net` | `System.Net.Sockets` | `TcpListener`, `TcpClient`, `Socket` |
| `net/http` | `System.Net.Http` / `Microsoft.AspNetCore` | Client: `HttpClient`; Server: Kestrel / minimal APIs |
| `net/url` | `System.Uri` | `Uri`, `UriBuilder` |
| `crypto/tls` | `System.Net.Security.SslStream` | Wrap `NetworkStream` with `SslStream` |
| `crypto/x509` | `System.Security.Cryptography.X509Certificates` | `X509Certificate2` |
| `crypto/sha256` | `System.Security.Cryptography.SHA256` | `SHA256.HashData()` (.NET 8+) |
| `crypto/ed25519` | `System.Security.Cryptography` | `Ed25519` support in .NET 9+ |
| `crypto/rand` | `System.Security.Cryptography.RandomNumberGenerator` | `RandomNumberGenerator.Fill()` |
| `time` | `System.TimeSpan` / `System.Threading.Timer` | `time.Duration` -> `TimeSpan`; `time.Ticker` -> `PeriodicTimer`; `time.After` -> `Task.Delay` |
| `time` (parsing) | `System.DateTime` / `System.DateTimeOffset` | `DateTime.Parse`, custom formats |
| `fmt` | String interpolation / `String.Format` | `$"..."` for most cases; `String.Format` for dynamic |
| `io` | `System.IO` | `Reader` -> `Stream`; `Writer` -> `Stream`; `io.Copy` -> `Stream.CopyTo` |
| `io/fs` | `System.IO` | `Directory`, `File`, `FileInfo` |
| `bufio` | `System.IO.BufferedStream` | Or `StreamReader` / `StreamWriter` |
| `bytes` | `System.Buffers` / `MemoryStream` | `bytes.Buffer` -> `MemoryStream` or `ArrayBufferWriter<byte>` |
| `strings` | `System.String` / `System.Text.StringBuilder` | Most methods have direct equivalents |
| `strconv` | `int.Parse`, `double.Parse`, etc. | Or `Convert` class |
| `context` | `CancellationToken` | `context.Context` -> `CancellationToken`; `context.WithCancel` -> `CancellationTokenSource` |
| `os` | `System.Environment` / `System.IO` | `os.Exit` -> `Environment.Exit`; file ops -> `File` class |
| `os/signal` | `System.Runtime.Loader.AssemblyLoadContext` | `UnloadingEvent` or `Console.CancelKeyPress` |
| `path/filepath` | `System.IO.Path` | `Path.Combine`, `Path.GetDirectoryName`, etc. |
| `sort` | `System.Linq` / `Array.Sort` | LINQ `.OrderBy()` or in-place `Array.Sort` |
| `math` | `System.Math` | Direct equivalent |
| `math/rand` | `System.Random` | `Random.Shared` for thread-safe usage (.NET 6+) |
| `regexp` | `System.Text.RegularExpressions.Regex` | Consider source generators for compiled patterns |
| `errors` | `System.Exception` | Go errors -> .NET exceptions; `errors.Is` -> pattern matching |
| `log` | `Serilog` | Project choice: Serilog via `Microsoft.Extensions.Logging` |
| `testing` | `xUnit` | `testing.T` -> xUnit `[Fact]`/`[Theory]`; `testing.B` -> BenchmarkDotNet |
| `flag` | `System.CommandLine` | Or `Microsoft.Extensions.Configuration` |
| `embed` | Embedded resources | `.csproj` `<EmbeddedResource>` items |
| `runtime` | `System.Runtime` / `System.Environment` | `runtime.GOOS` -> `RuntimeInformation.IsOSPlatform` |
### NATS Ecosystem Libraries
| Go Package | .NET Equivalent | Notes |
|---|---|---|
| `github.com/nats-io/jwt/v2` | Custom / evaluate existing | JWT claims for NATS auth; may need custom implementation matching NATS JWT spec |
| `github.com/nats-io/nkeys` | Custom implementation | Ed25519 key pairs for NATS authentication; use `System.Security.Cryptography` Ed25519 |
| `github.com/nats-io/nuid` | Custom / `System.Guid` | NATS unique IDs; simple custom implementation or adapt to `Guid` if format is flexible |
| `github.com/nats-io/nats.go` | `NATS.Net` (official) | Only used in tests; the official .NET NATS client |
### Third-Party Libraries
| Go Package | .NET Equivalent | Notes |
|---|---|---|
| `github.com/klauspost/compress` | `System.IO.Compression` | General compression: `GZipStream`, `DeflateStream`. S2/Snappy: evaluate IronSnappy NuGet or custom port |
| `github.com/minio/highwayhash` | Custom / NuGet | HighwayHash implementation; search NuGet or port the algorithm |
| `golang.org/x/crypto` | `System.Security.Cryptography` | `bcrypt` -> `Rfc2898DeriveBytes` or BCrypt.Net NuGet; `argon2` -> Konscious.Security NuGet |
| `golang.org/x/sys` | `System.Runtime.InteropServices` | Platform-specific syscalls -> P/Invoke or `RuntimeInformation` |
| `golang.org/x/time` | `System.Threading.RateLimiting` | `rate.Limiter` -> `RateLimiter` (.NET 7+); `TokenBucketRateLimiter` or `SlidingWindowRateLimiter` |
| `golang.org/x/text` | `System.Globalization` | Unicode normalization, encoding detection |
## Mapping Decision Guidelines
When choosing a .NET equivalent, follow these priorities:
1. **BCL first**: Prefer built-in .NET Base Class Library types over NuGet packages.
2. **Official packages second**: If BCL does not cover it, prefer
`Microsoft.*` or `System.*` NuGet packages.
3. **Well-maintained NuGet third**: Choose packages with active maintenance,
high download counts, and compatible licenses.
4. **Custom implementation last**: Only write custom code when no suitable
package exists. Document the rationale in the mapping notes.
For each mapping, consider:
- **API surface**: Does the .NET equivalent cover all methods used in the Go code?
- **Performance**: Are there performance-critical paths that need benchmarking?
- **Thread safety**: Go's concurrency model differs from .NET. Note any
synchronization concerns.
- **Platform support**: Does the .NET package work on all target platforms
(Linux, macOS, Windows)?
## Completion Criteria
Phase 3 is complete when ALL of the following are true:
- [ ] [#30](https://gitea.dohertylan.com/dohertj2/natsnet/issues/30) `library suggest` returns "All libraries have been mapped!"
- [ ] [#21](https://gitea.dohertylan.com/dohertj2/natsnet/issues/21) Every entry in `library list` shows status `mapped` or `verified`.
- [ ] [#22](https://gitea.dohertylan.com/dohertj2/natsnet/issues/22) Each mapping includes a `--package` (the NuGet package or BCL assembly),
a `--namespace` (the .NET namespace to use), and `--notes` (migration
guidance).
- [ ] [#23](https://gitea.dohertylan.com/dohertj2/natsnet/issues/23) `phase check 1` shows all items checked including "All libraries mapped".
- [ ] [#24](https://gitea.dohertylan.com/dohertj2/natsnet/issues/24) A mapping report has been exported for reference.
- [ ] Close the Phase 3 milestone in Gitea:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/milestones/3" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close it via the Gitea web UI at https://gitea.dohertylan.com/dohertj2/natsnet/milestone/3
## Troubleshooting
### "Library <id> not found"
The ID you passed to `library map` does not exist. Run `library suggest` to get
the current list of IDs and their import paths.
### Unsure which .NET package to use
For unfamiliar Go packages:
1. Check what the package does in the Go source (look at the import usage in the
files listed by the analyzer).
2. Search NuGet.org for equivalent functionality.
3. Check if the Go package is a thin wrapper around a well-known algorithm that
.NET implements natively.
4. When in doubt, map it as "Custom" with detailed notes and revisit during
the porting phase.
### Multiple .NET options for one Go package
When there are several valid .NET equivalents (e.g., `Newtonsoft.Json` vs
`System.Text.Json`), prefer the one that:
- Is part of the BCL or a Microsoft package.
- Has better performance characteristics.
- Has source generator support for AOT compilation.
Record the alternatives in the `--notes` field so the decision can be revisited.
### Stdlib packages showing as unmapped
The analyzer classifies imports as stdlib vs external based on whether the first
path component contains a dot. Standard library packages like `encoding/json`,
`net/http`, etc. should still be recorded in the library_mappings table so that
every import path has a documented .NET migration path. Map them using the
reference table above.

View File

@@ -0,0 +1,223 @@
# Phase 4: .NET Solution Design
Design the target .NET 10 solution structure and map every Go item to its .NET counterpart. This phase translates the Go codebase decomposition (from Phases 1-2) and library mappings (from Phase 3) into a concrete .NET implementation plan.
## Objective
Every module, feature, and test in the porting database must have either a .NET mapping (project, namespace, class, method) or a justified N/A status. The result is a complete blueprint for the porting work in Phase 6.
## Prerequisites
- Phases 1-3 complete: all Go items in the DB, all libraries mapped
- Verify with: `dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db`
- Read and follow the [.NET Coding Standards](../../standards/dotnet-standards.md) — all naming, testing, and logging decisions must comply
## Source and Target Locations
- **Go source code** is located in the `golang/` folder (specifically `golang/nats-server/`)
- **.NET ported version** is located in the `dotnet/` folder
## Milestone Tracking
This phase corresponds to **Milestone 4** in [Gitea](https://gitea.dohertylan.com/dohertj2/natsnet/milestone/4). When starting this phase, verify the milestone is open. Assign relevant issues to this milestone as work progresses.
### Issue Updates
Each completion criterion has a corresponding Gitea issue. Update issues as you work:
- **Starting a criterion**: Add a comment noting work has begun
- **Blocked**: Add a comment describing the blocker
- **Complete**: Close the issue with a comment summarizing the result (e.g., counts, verification output)
Close issues via CLI:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/issues/{N}" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close via the Gitea web UI.
## Solution Structure
Define the .NET solution layout following standard conventions:
```
dotnet/
ZB.MOM.NatsNet.sln
src/
ZB.MOM.NatsNet.Server/ # Main server library (all core logic)
Protocol/ # Wire protocol parsing, commands
Subscriptions/ # SubList trie, subject matching
JetStream/ # Stream management, consumers
Cluster/ # Routes, gateways, leaf nodes
Auth/ # Authentication, accounts, JWT
...
ZB.MOM.NatsNet.Server.Host/ # Host/entry point (Program.cs, DI, config)
tests/
ZB.MOM.NatsNet.Server.Tests/ # Unit tests for ZB.MOM.NatsNet.Server
Protocol/
Subscriptions/
JetStream/
...
ZB.MOM.NatsNet.Server.IntegrationTests/ # Cross-module and end-to-end tests
```
The `ZB.MOM.NatsNet.Server` project holds all portable logic. `ZB.MOM.NatsNet.Server.Host` is the thin entry point that wires up dependency injection, configuration, and hosting. Tests mirror the source structure.
## Naming Conventions
Follow these rules consistently when mapping Go items to .NET:
| Aspect | Convention | Example |
|--------|-----------|---------|
| Classes | PascalCase | `NatsParser`, `SubList`, `JetStreamController` |
| Methods | PascalCase | `TryParse`, `Match`, `ProcessMessage` |
| Namespaces | `ZB.MOM.NatsNet.Server.[Module]` | `ZB.MOM.NatsNet.Server.Protocol`, `ZB.MOM.NatsNet.Server.Subscriptions` |
| Test classes | `[ClassName]Tests` | `NatsParserTests`, `SubListTests` |
| Test methods | `[Method]_[Scenario]_[Expected]` | `TryParse_ValidInput_ReturnsTrue` |
| Interfaces | `I[Name]` | `IMessageRouter`, `ISubListAccess` |
| Projects | `ZB.MOM.NatsNet.Server[.Suffix]` | `ZB.MOM.NatsNet.Server`, `ZB.MOM.NatsNet.Server.Host` |
Avoid abbreviations unless they are universally understood (e.g., `TCP`, `TLS`, `JWT`). Prefer descriptive names over short ones.
## Steps
### Step 1: Map modules
For each module in the database, assign a .NET project, namespace, and class. The `--namespace` and `--class` options are optional but recommended.
```bash
# List all modules to review
dotnet run --project tools/NatsNet.PortTracker -- module list --db porting.db
# Map a module to its .NET target
dotnet run --project tools/NatsNet.PortTracker -- module map <id> \
--project "ZB.MOM.NatsNet.Server" \
--namespace "ZB.MOM.NatsNet.Server.Protocol" \
--class "NatsParser" \
--db porting.db
```
Work through all modules systematically. Group related Go files into the same namespace:
| Go package/file pattern | .NET namespace |
|------------------------|----------------|
| `server/parser.go` | `ZB.MOM.NatsNet.Server.Protocol` |
| `server/sublist.go` | `ZB.MOM.NatsNet.Server.Subscriptions` |
| `server/jetstream*.go` | `ZB.MOM.NatsNet.Server.JetStream` |
| `server/route.go`, `server/gateway.go` | `ZB.MOM.NatsNet.Server.Cluster` |
| `server/auth.go`, `server/accounts.go` | `ZB.MOM.NatsNet.Server.Auth` |
| `server/pse/` | Likely N/A (Go-specific platform code) |
### Step 2: Map features
For each feature (function/method), assign the .NET class and method name:
```bash
# List features for a specific module
dotnet run --project tools/NatsNet.PortTracker -- feature list --module <module_id> --db porting.db
# Map a feature
dotnet run --project tools/NatsNet.PortTracker -- feature map <id> \
--project "ZB.MOM.NatsNet.Server" \
--class "NatsParser" \
--method "TryParse" \
--db porting.db
```
When mapping Go functions to .NET methods:
- Go free functions become static methods or instance methods on the appropriate class
- Go methods with receivers map to instance methods on the corresponding .NET class
- Go `init()` functions typically map to static constructors or initialization in DI setup
- Go `goroutine` launches map to `Task`-based async methods
### Step 3: Map tests
For each test function, assign the .NET test class and method:
```bash
# List tests for a module
dotnet run --project tools/NatsNet.PortTracker -- test list --module <module_id> --db porting.db
# Map a test
dotnet run --project tools/NatsNet.PortTracker -- test map <id> \
--project "ZB.MOM.NatsNet.Server.Tests" \
--class "NatsParserTests" \
--method "TryParse_ValidInput_ReturnsTrue" \
--db porting.db
```
Go test naming (`TestParserValid`) translates to .NET naming (`TryParse_ValidInput_ReturnsTrue`). Each Go `Test*` function maps to one or more `[Fact]` or `[Theory]` methods. Table-driven Go tests often become `[Theory]` with `[InlineData]` or `[MemberData]`. Use Shouldly for assertions and NSubstitute for mocking — see the [.NET Coding Standards](../../standards/dotnet-standards.md) for details.
### Step 4: Mark N/A items
Some Go code has no .NET equivalent. Mark these with a clear reason:
```bash
# Mark a module as N/A
dotnet run --project tools/NatsNet.PortTracker -- module set-na <id> \
--reason "Go-specific platform code, not needed in .NET" \
--db porting.db
# Mark a feature as N/A
dotnet run --project tools/NatsNet.PortTracker -- feature set-na <id> \
--reason "Go signal handling, replaced by .NET host lifecycle" \
--db porting.db
```
### Common N/A categories
Items that typically do not need a .NET port:
| Go item | Reason |
|---------|--------|
| `pse_darwin.go`, `pse_linux.go`, `pse_windows.go` | Go-specific platform syscall wrappers; use .NET `System.Diagnostics.Process` instead |
| `disk_avail_windows.go`, `disk_avail_linux.go` | Go-specific disk APIs; use .NET `System.IO.DriveInfo` instead |
| Custom logger (`logger.go`, `log.go`) | Replaced by Serilog via `ZB.MOM.NatsNet.Server.Host` |
| Signal handling (`signal.go`) | Replaced by .NET Generic Host `IHostLifetime` |
| Go `sync.Pool`, `sync.Map` wrappers | .NET has `ObjectPool<T>`, `ConcurrentDictionary<K,V>` built-in |
| Build tags / `_test.go` helpers specific to Go test infra | Replaced by xUnit attributes and test fixtures |
| `go:embed` directives | Replaced by embedded resources or `IFileProvider` |
Every N/A must include a reason. Bare N/A status without explanation is not acceptable.
## Verification
After mapping all items, run a quick check:
```bash
# Count unmapped items (should be 0)
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
# Review all modules — every row should show DotNet Project filled or status n_a
dotnet run --project tools/NatsNet.PortTracker -- module list --db porting.db
# Review N/A items to confirm they all have reasons
dotnet run --project tools/NatsNet.PortTracker -- module list --status n_a --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- feature list --status n_a --db porting.db
```
## Completion Criteria
- [ ] [#25](https://gitea.dohertylan.com/dohertj2/natsnet/issues/25) Every module has `dotnet_project` and `dotnet_namespace` set, or status is `n_a` with a reason
- [ ] [#26](https://gitea.dohertylan.com/dohertj2/natsnet/issues/26) Every feature has `dotnet_project`, `dotnet_class`, and `dotnet_method` set, or status is `n_a` with a reason
- [ ] [#27](https://gitea.dohertylan.com/dohertj2/natsnet/issues/27) Every test has `dotnet_project`, `dotnet_class`, and `dotnet_method` set, or status is `n_a` with a reason
- [ ] [#28](https://gitea.dohertylan.com/dohertj2/natsnet/issues/28) Naming follows PascalCase and the namespace hierarchy described above
- [ ] [#29](https://gitea.dohertylan.com/dohertj2/natsnet/issues/29) No two features map to the same class + method combination (collisions)
- [ ] Close the Phase 4 milestone in Gitea:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/milestones/4" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close it via the Gitea web UI at https://gitea.dohertylan.com/dohertj2/natsnet/milestone/4
## Related Documentation
- [Phase 3: Library Mapping](phase-3-library-mapping.md) -- library mappings inform .NET class choices
- [Phase 5: Mapping Verification](phase-5-mapping-verification.md) -- next phase, validates all mappings
- [Phase 6: Porting](phase-6-porting.md) -- uses these mappings as the implementation blueprint

View File

@@ -0,0 +1,228 @@
# Phase 5: Mapping Verification
Verify that every Go item in the porting database is either mapped to a .NET target or justified as N/A. This phase is a quality gate between design (Phase 4) and implementation (Phase 6).
## Objective
Confirm zero unmapped items, validate all N/A justifications, enforce naming conventions, and detect collisions. The porting database must be a complete, consistent blueprint before any code is written.
## Prerequisites
- Phase 4 complete: all items have .NET mappings or N/A status
- Verify with: `dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db`
- Naming verification must check compliance with the [.NET Coding Standards](../../standards/dotnet-standards.md)
## Source and Target Locations
- **Go source code** is located in the `golang/` folder (specifically `golang/nats-server/`)
- **.NET ported version** is located in the `dotnet/` folder
## Milestone Tracking
This phase corresponds to **Milestone 5** in [Gitea](https://gitea.dohertylan.com/dohertj2/natsnet/milestone/5). When starting this phase, verify the milestone is open. Assign relevant issues to this milestone as work progresses.
### Issue Updates
Each completion criterion has a corresponding Gitea issue. Update issues as you work:
- **Starting a criterion**: Add a comment noting work has begun
- **Blocked**: Add a comment describing the blocker
- **Complete**: Close the issue with a comment summarizing the result (e.g., counts, verification output)
Close issues via CLI:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/issues/{N}" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close via the Gitea web UI.
## Steps
### Step 1: Confirm zero unmapped items
Run the summary report and verify that no items remain in `not_started` status without a .NET mapping:
```bash
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
```
The output shows counts per status. All items should be in one of these categories:
- `not_started` with .NET mapping fields populated (ready for Phase 6)
- `n_a` with a reason in the notes field
If any items lack both a mapping and N/A status, go back to Phase 4 and address them.
### Step 2: Review N/A items
Every N/A item must have a justification. Review them by type:
```bash
# Review N/A modules
dotnet run --project tools/NatsNet.PortTracker -- module list --status n_a --db porting.db
# Review N/A features
dotnet run --project tools/NatsNet.PortTracker -- feature list --status n_a --db porting.db
# Review N/A tests
dotnet run --project tools/NatsNet.PortTracker -- test list --status n_a --db porting.db
```
For each N/A item, verify:
1. The reason is documented (check with `module show <id>`, `feature show <id>`, or `test show <id>`)
2. The reason is valid (the item genuinely has no .NET equivalent or is replaced by a .NET facility)
3. No dependent items rely on this N/A item being ported
```bash
# Check if anything depends on an N/A item
dotnet run --project tools/NatsNet.PortTracker -- dependency show module <id> --db porting.db
dotnet run --project tools/NatsNet.PortTracker -- dependency show feature <id> --db porting.db
```
If a non-N/A item depends on an N/A item, either the dependency needs to be resolved differently or the N/A classification is wrong.
### Step 3: Verify naming conventions
Walk through the mappings and check for naming compliance:
**PascalCase check**: All `dotnet_class` and `dotnet_method` values must use PascalCase. No `snake_case`, no `camelCase`.
```bash
# List all mapped modules and spot-check names
dotnet run --project tools/NatsNet.PortTracker -- module list --db porting.db
# List all mapped features for a module and check class/method names
dotnet run --project tools/NatsNet.PortTracker -- feature list --module <id> --db porting.db
```
**Namespace hierarchy check**: Namespaces must follow `ZB.MOM.NatsNet.Server.[Module]` pattern:
| Valid | Invalid |
|-------|---------|
| `ZB.MOM.NatsNet.Server.Protocol` | `Protocol` (missing root) |
| `ZB.MOM.NatsNet.Server.JetStream` | `ZB.MOM.NatsNet.Server.jetstream` (wrong case) |
| `ZB.MOM.NatsNet.Server.Subscriptions` | `NATSServer.Subscriptions` (wrong root) |
**Test naming check**: Test classes must end in `Tests`. Test methods must follow `[Method]_[Scenario]_[Expected]` pattern:
| Valid | Invalid |
|-------|---------|
| `NatsParserTests` | `ParserTest` (wrong suffix) |
| `TryParse_ValidInput_ReturnsTrue` | `TestParserValid` (Go-style naming) |
| `Match_WildcardSubject_ReturnsSubscribers` | `test_match` (snake_case) |
### Step 4: Check for collisions
No two features should map to the same class + method combination. This would cause compile errors or overwrite conflicts.
```bash
# Export the full mapping report for review
dotnet run --project tools/NatsNet.PortTracker -- report export --format md --output porting-mapping-report.md --db porting.db
```
Open `porting-mapping-report.md` and search for duplicate class + method pairs. If the database is large, run a targeted SQL query:
```bash
sqlite3 porting.db "
SELECT dotnet_class, dotnet_method, COUNT(*) as cnt
FROM features
WHERE dotnet_class IS NOT NULL AND dotnet_method IS NOT NULL
GROUP BY dotnet_class, dotnet_method
HAVING cnt > 1;
"
```
If collisions are found, rename one of the conflicting methods. Common resolution: add a more specific suffix (`ParseHeaders` vs `ParseBody` instead of two `Parse` methods).
### Step 5: Validate cross-references
Verify that test mappings reference the correct test project:
```bash
# All tests should target ZB.MOM.NatsNet.Server.Tests or ZB.MOM.NatsNet.Server.IntegrationTests
dotnet run --project tools/NatsNet.PortTracker -- test list --db porting.db
```
Check that:
- Unit tests point to `ZB.MOM.NatsNet.Server.Tests`
- Integration tests (if any) point to `ZB.MOM.NatsNet.Server.IntegrationTests`
- No tests accidentally point to `ZB.MOM.NatsNet.Server` (the library project)
### Step 6: Run phase check
Run the built-in phase verification:
```bash
dotnet run --project tools/NatsNet.PortTracker -- phase check 5 --db porting.db
```
This runs automated checks and reports any remaining issues. All checks must pass.
### Step 7: Export final mapping report
Generate the definitive mapping report that serves as the implementation reference for Phase 6:
```bash
dotnet run --project tools/NatsNet.PortTracker -- report export \
--format md \
--output porting-mapping-report.md \
--db porting.db
```
Review the exported report for completeness. This document becomes the source of truth for the porting work.
## Troubleshooting
### Unmapped items found
```bash
# Find features with no .NET mapping and not N/A
dotnet run --project tools/NatsNet.PortTracker -- feature list --status not_started --db porting.db
```
For each unmapped item, either map it (Phase 4 Step 2) or set it to N/A with a reason.
### N/A item has dependents
If a non-N/A feature depends on an N/A feature:
1. Determine if the dependency is real or an artifact of the Go call graph
2. If real, the N/A classification is likely wrong -- map the item instead
3. If the dependency is Go-specific, remove or reclassify it
### Naming collision detected
Rename one of the colliding methods to be more specific:
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature map <id> \
--method "ParseHeadersFromBuffer" \
--db porting.db
```
## Completion Criteria
- [ ] [#31](https://gitea.dohertylan.com/dohertj2/natsnet/issues/31) Zero items in `not_started` status without a .NET mapping
- [ ] [#32](https://gitea.dohertylan.com/dohertj2/natsnet/issues/32) All N/A items have a documented, valid reason
- [ ] [#33](https://gitea.dohertylan.com/dohertj2/natsnet/issues/33) All `dotnet_class` and `dotnet_method` values follow PascalCase
- [ ] [#34](https://gitea.dohertylan.com/dohertj2/natsnet/issues/34) All namespaces follow `ZB.MOM.NatsNet.Server.[Module]` hierarchy
- [ ] [#35](https://gitea.dohertylan.com/dohertj2/natsnet/issues/35) No two features map to the same class + method combination
- [ ] [#36](https://gitea.dohertylan.com/dohertj2/natsnet/issues/36) All tests target the correct test project
- [ ] [#37](https://gitea.dohertylan.com/dohertj2/natsnet/issues/37) `phase check 5` passes with no errors
- [ ] [#38](https://gitea.dohertylan.com/dohertj2/natsnet/issues/38) Mapping report exported and reviewed
- [ ] Close the Phase 5 milestone in Gitea:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/milestones/5" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close it via the Gitea web UI at https://gitea.dohertylan.com/dohertj2/natsnet/milestone/5
## Related Documentation
- [Phase 4: .NET Solution Design](phase-4-dotnet-design.md) -- the mapping phase this verifies
- [Phase 6: Porting](phase-6-porting.md) -- uses the verified mappings for implementation

View File

@@ -0,0 +1,291 @@
# Phase 6: Initial Porting
Port Go code to .NET 10 C#, working through the dependency graph bottom-up. This is the main implementation phase where the actual code is written.
## Objective
Implement every non-N/A module, feature, and test in the porting database. Work from leaf nodes (items with no unported dependencies) upward through the dependency graph. Keep the database current as work progresses.
## Prerequisites
- Phase 5 complete: all mappings verified, no collisions, naming validated
- Read and follow the [.NET Coding Standards](../../standards/dotnet-standards.md) — covers testing (xUnit 3 / Shouldly / NSubstitute), logging (Microsoft.Extensions.Logging + Serilog + LogContext), async patterns, and performance guidelines
- .NET solution structure created:
- `dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj`
- `dotnet/src/ZB.MOM.NatsNet.Server.Host/ZB.MOM.NatsNet.Server.Host.csproj`
- `dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj`
- `dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/ZB.MOM.NatsNet.Server.IntegrationTests.csproj`
- Library dependencies (NuGet packages) added per Phase 3 mappings
- Verify readiness: `dotnet run --project tools/NatsNet.PortTracker -- phase check 5 --db porting.db`
## Source and Target Locations
- **Go source code** is located in the `golang/` folder (specifically `golang/nats-server/`)
- **.NET ported version** is located in the `dotnet/` folder
## Milestone Tracking
This phase corresponds to **Milestone 6** in [Gitea](https://gitea.dohertylan.com/dohertj2/natsnet/milestone/6). When starting this phase, verify the milestone is open. Assign relevant issues to this milestone as work progresses.
### Issue Updates
Each completion criterion has a corresponding Gitea issue. Update issues as you work:
- **Starting a criterion**: Add a comment noting work has begun
- **Blocked**: Add a comment describing the blocker
- **Complete**: Close the issue with a comment summarizing the result (e.g., counts, verification output)
Close issues via CLI:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/issues/{N}" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close via the Gitea web UI.
## Porting Workflow
This is the core loop. Repeat until all items are complete.
### Step 1: Find ready items
Query for items whose dependencies are all ported (status `complete`, `verified`, or `n_a`):
```bash
dotnet run --project tools/NatsNet.PortTracker -- dependency ready --db porting.db
```
This returns modules and features that have no unported dependencies. Start with these.
### Step 2: Pick an item and mark as stub
Choose an item from the ready list. Mark it as `stub` to signal work has begun:
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update <id> --status stub --db porting.db
```
### Step 3: Create the skeleton
In the .NET project, create the class and method skeleton based on the mapping:
1. Look up the mapping: `dotnet run --project tools/NatsNet.PortTracker -- feature show <id> --db porting.db`
2. Create the file at the correct path under `dotnet/src/ZB.MOM.NatsNet.Server/` following the namespace hierarchy
3. Add the class declaration, method signature, and a `throw new NotImplementedException()` body
For batch scaffolding of an entire module:
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status stub \
--all-in-module <module_id> --db porting.db
```
### Step 4: Implement the logic
Reference the Go source code. The database stores the Go file path and line number for each feature:
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature show <id> --db porting.db
```
The output includes `Go File`, `Go Line`, and `Go LOC` fields. Open the Go source at those coordinates and translate the logic to C#.
Key translation patterns:
| Go pattern | .NET equivalent |
|-----------|-----------------|
| `goroutine` + `channel` | `Task` + `Channel<T>` or `async/await` |
| `sync.Mutex` | `lock` statement or `SemaphoreSlim` |
| `sync.RWMutex` | `ReaderWriterLockSlim` |
| `sync.WaitGroup` | `Task.WhenAll` or `CountdownEvent` |
| `defer` | `try/finally` or `using`/`IDisposable` |
| `interface{}` / `any` | `object` or generics |
| `[]byte` | `byte[]`, `ReadOnlySpan<byte>`, or `ReadOnlyMemory<byte>` |
| `map[K]V` | `Dictionary<K,V>` or `ConcurrentDictionary<K,V>` |
| `error` return | Exceptions or `Result<T>` pattern |
| `panic/recover` | Exceptions (avoid `Environment.FailFast` for recoverable cases) |
| `select` on channels | `Task.WhenAny` or `Channel<T>` reader patterns |
| `context.Context` | `CancellationToken` |
| `io.Reader/Writer` | `Stream`, `PipeReader/PipeWriter` |
### Step 5: Mark complete
Once the implementation compiles and the basic logic is in place:
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature update <id> --status complete --db porting.db
```
### Step 6: Run targeted tests
If tests exist for this feature, run them:
```bash
dotnet test --filter "FullyQualifiedName~ZB.MOM.NatsNet.Server.Tests.Protocol" \
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/
```
Fix any failures before moving on.
### Step 7: Check what is now unblocked
Completing items may unblock others that depend on them:
```bash
dotnet run --project tools/NatsNet.PortTracker -- dependency ready --db porting.db
```
Return to Step 2 with the newly available items.
## DB Update Discipline
The porting database must stay current. Update status at every transition:
```bash
# Starting work on a feature
dotnet run --project tools/NatsNet.PortTracker -- feature update 42 --status stub --db porting.db
# Feature implemented
dotnet run --project tools/NatsNet.PortTracker -- feature update 42 --status complete --db porting.db
# Batch scaffolding for all features in a module
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status stub \
--all-in-module 3 --db porting.db
# Module fully ported (all its features are complete)
dotnet run --project tools/NatsNet.PortTracker -- module update 3 --status complete --db porting.db
# Check progress
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
```
Status transitions follow this progression:
```
not_started -> stub -> complete -> verified (Phase 7)
\-> n_a (if determined during porting)
```
Never skip `stub` -- it signals that work is in progress and prevents duplicate effort.
## Porting Order Strategy
### Start with leaf modules
Leaf modules have no dependencies on other unported modules. They are safe to port first because nothing they call is missing.
```bash
# These are the leaves — port them first
dotnet run --project tools/NatsNet.PortTracker -- dependency ready --db porting.db
```
Typical leaf modules include:
- Utility/helper code (string manipulation, byte buffer pools)
- Constants and enums
- Configuration types (options, settings)
- Error types and codes
### Then work upward
After leaves are done, modules that depended only on those leaves become ready. Continue up the dependency graph:
```
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:
```bash
# List tests for a feature
dotnet run --project tools/NatsNet.PortTracker -- test list --module <module_id> --db porting.db
# After porting a test
dotnet run --project tools/NatsNet.PortTracker -- test update <id> --status complete --db porting.db
```
## Progress Tracking
Check overall progress regularly:
```bash
# Summary stats
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
# What is still blocked
dotnet run --project tools/NatsNet.PortTracker -- dependency blocked --db porting.db
# Phase-level check
dotnet run --project tools/NatsNet.PortTracker -- phase check 6 --db porting.db
```
## Handling Discoveries During Porting
During implementation, you may find:
### Items that should be N/A
If a feature turns out to be unnecessary in .NET (discovered during implementation):
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature set-na <id> \
--reason "Go-specific memory management, handled by .NET GC" --db porting.db
```
### Missing dependencies
If the Go analyzer missed a dependency:
```bash
# The dependency is tracked in the DB via the dependencies table
# For now, just ensure the target is ported before continuing
dotnet run --project tools/NatsNet.PortTracker -- dependency show feature <id> --db porting.db
```
### Design changes
If the .NET design needs to differ from the original mapping (e.g., splitting a large Go function into multiple .NET methods), update the mapping:
```bash
dotnet run --project tools/NatsNet.PortTracker -- feature map <id> \
--class "NewClassName" \
--method "NewMethodName" \
--db porting.db
```
## Tips
1. **Keep the build green.** The solution should compile after each feature is completed. Do not leave unresolved references.
2. **Write idiomatic C#.** Do not transliterate Go line-by-line. Use .NET patterns (async/await, LINQ, Span, dependency injection) where they produce cleaner code.
3. **Use `CancellationToken` everywhere.** The Go code uses `context.Context` pervasively -- mirror this with `CancellationToken` parameters.
4. **Prefer `ReadOnlySpan<byte>` for hot paths.** The NATS parser processes bytes at high throughput. Use spans and avoid allocations in the critical path.
5. **Do not port Go comments verbatim.** Translate the intent into C# XML doc comments where appropriate.
6. **Run `dotnet build` frequently.** Catch compile errors early rather than accumulating them.
## Completion Criteria
- [ ] [#39](https://gitea.dohertylan.com/dohertj2/natsnet/issues/39) All non-N/A modules have status `complete` or better
- [ ] [#40](https://gitea.dohertylan.com/dohertj2/natsnet/issues/40) All non-N/A features have status `complete` or better
- [ ] [#41](https://gitea.dohertylan.com/dohertj2/natsnet/issues/41) All non-N/A tests have status `complete` or better
- [ ] [#42](https://gitea.dohertylan.com/dohertj2/natsnet/issues/42) The solution compiles without errors: `dotnet build`
- [ ] [#43](https://gitea.dohertylan.com/dohertj2/natsnet/issues/43) `dependency blocked` returns no items (or only items waiting for Phase 7 verification)
- [ ] [#44](https://gitea.dohertylan.com/dohertj2/natsnet/issues/44) `report summary` shows the expected completion counts
- [ ] Close the Phase 6 milestone in Gitea:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/milestones/6" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close it via the Gitea web UI at https://gitea.dohertylan.com/dohertj2/natsnet/milestone/6
## Related Documentation
- [Phase 5: Mapping Verification](phase-5-mapping-verification.md) -- the verified mappings this phase implements
- [Phase 7: Porting Verification](phase-7-porting-verification.md) -- targeted testing of the ported code

View File

@@ -0,0 +1,275 @@
# Phase 7: Porting Verification
Verify all ported code through targeted testing per module. This phase does NOT run the full test suite as a single pass -- it systematically verifies each module, marks items as verified, and confirms behavioral equivalence with the Go server.
## Objective
Every ported module passes its targeted tests. Every item in the database reaches `verified` or `n_a` status. Cross-module integration tests pass. Key behavioral scenarios produce equivalent results between the Go and .NET servers.
## Prerequisites
- Phase 6 complete: all non-N/A items at `complete` or better
- All tests ported and compilable per [.NET Coding Standards](../../standards/dotnet-standards.md) (xUnit 3 / Shouldly / NSubstitute)
- Verify readiness: `dotnet run --project tools/NatsNet.PortTracker -- phase check 6 --db porting.db`
## Source and Target Locations
- **Go source code** is located in the `golang/` folder (specifically `golang/nats-server/`)
- **.NET ported version** is located in the `dotnet/` folder
## Milestone Tracking
This phase corresponds to **Milestone 7** in [Gitea](https://gitea.dohertylan.com/dohertj2/natsnet/milestone/7). When starting this phase, verify the milestone is open. Assign relevant issues to this milestone as work progresses.
### Issue Updates
Each completion criterion has a corresponding Gitea issue. Update issues as you work:
- **Starting a criterion**: Add a comment noting work has begun
- **Blocked**: Add a comment describing the blocker
- **Complete**: Close the issue with a comment summarizing the result (e.g., counts, verification output)
Close issues via CLI:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/issues/{N}" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close via the Gitea web UI.
## Verification Workflow
Work through modules one at a time. Do not move to the next module until the current one is fully verified.
### Step 1: List modules to verify
```bash
# Show all modules — look for status 'complete' (not yet verified)
dotnet run --project tools/NatsNet.PortTracker -- module list --db porting.db
```
Start with leaf modules (those with the fewest dependencies) and work upward, same order as the porting phase.
### Step 2: List tests for the module
For each module, identify all mapped tests:
```bash
dotnet run --project tools/NatsNet.PortTracker -- test list --module <module_id> --db porting.db
```
This shows every test associated with the module, its status, and its .NET method name.
### Step 3: Run targeted tests
Run only the tests for this module using `dotnet test --filter`:
```bash
# Filter by namespace (matches all tests in the module's namespace)
dotnet test --filter "FullyQualifiedName~ZB.MOM.NatsNet.Server.Tests.Protocol" \
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/
# Filter by test class
dotnet test --filter "FullyQualifiedName~ZB.MOM.NatsNet.Server.Tests.Protocol.NatsParserTests" \
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/
# Filter by specific test method
dotnet test --filter "FullyQualifiedName~ZB.MOM.NatsNet.Server.Tests.Protocol.NatsParserTests.TryParse_ValidInput_ReturnsTrue" \
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/
```
The `--filter` flag uses partial matching on the fully qualified test name. Use the namespace pattern for module-wide runs, and the class or method pattern for debugging specific failures.
### Step 4: Handle failures
When tests fail:
1. **Read the failure output.** The test runner prints the assertion that failed, the expected vs actual values, and the stack trace.
2. **Locate the Go reference.** Look up the test in the database to find the original Go test and source:
```bash
dotnet run --project tools/NatsNet.PortTracker -- test show <test_id> --db porting.db
```
3. **Compare Go and .NET logic.** Open the Go source at the stored line number. Check for translation errors: off-by-one, missing edge cases, different default values.
4. **Fix and re-run.** After fixing, re-run only the failing test:
```bash
dotnet test --filter "FullyQualifiedName~ZB.MOM.NatsNet.Server.Tests.Protocol.NatsParserTests.TryParse_EmptyInput_ReturnsFalse" \
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/
```
5. **Then re-run the full module.** Confirm no regressions:
```bash
dotnet test --filter "FullyQualifiedName~ZB.MOM.NatsNet.Server.Tests.Protocol" \
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/
```
Common failure causes:
| Symptom | Likely cause |
|---------|-------------|
| Off-by-one in buffer parsing | Go slices are half-open `[start:end)`, C# spans match but array indexing might differ |
| Timeout in async test | Missing `CancellationToken`, or `Task` not awaited |
| Wrong byte sequence | Go uses `[]byte("string")` which is UTF-8; ensure C# uses `Encoding.UTF8` |
| Nil vs null behavior | Go nil checks behave differently from C# null; check for `default` values |
| Map iteration order | Go maps iterate in random order; if the test depends on order, sort first |
### Step 5: Mark module as verified
Once all tests pass for a module:
```bash
# Mark the module itself as verified
dotnet run --project tools/NatsNet.PortTracker -- module update <module_id> --status verified --db porting.db
# Mark all features in the module as verified
dotnet run --project tools/NatsNet.PortTracker -- feature update 0 --status verified \
--all-in-module <module_id> --db porting.db
# Mark individual tests as verified
dotnet run --project tools/NatsNet.PortTracker -- test update <test_id> --status verified --db porting.db
```
### Step 6: Move to next module
Repeat Steps 2-5 for each module. Track progress:
```bash
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
```
## Integration Testing
After all modules are individually verified, run integration tests that exercise cross-module behavior.
### Step 7: Run integration tests
```bash
dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/
```
Integration tests cover scenarios like:
- Client connects, subscribes, receives published messages
- Multiple clients with wildcard subscriptions
- Connection lifecycle (connect, disconnect, reconnect)
- Protocol error handling (malformed commands, oversized payloads)
- Configuration loading and server startup
Fix any failures by tracing through the modules involved and checking the interaction boundaries.
### Step 8: Behavioral comparison
Run both the Go server and the .NET server with the same workload and compare behavior. This catches semantic differences that unit tests might miss.
**Setup:**
1. Start the Go server:
```bash
cd golang/nats-server && go run . -p 4222
```
2. Start the .NET server:
```bash
dotnet run --project dotnet/src/ZB.MOM.NatsNet.Server.Host -- --port 4223
```
**Comparison scenarios:**
| Scenario | What to compare |
|----------|----------------|
| Basic pub/sub | Publish a message, verify subscriber receives identical payload |
| Wildcard matching | Subscribe with `foo.*` and `foo.>`, publish to `foo.bar`, verify same match results |
| Queue groups | Multiple subscribers in a queue group, verify round-robin distribution |
| Protocol errors | Send malformed commands, verify same error responses |
| Connection info | Connect and check `INFO` response fields |
| Graceful shutdown | Send SIGTERM, verify clean disconnection |
Use the `nats` CLI tool to drive traffic:
```bash
# Subscribe on Go server
nats sub -s nats://localhost:4222 "test.>"
# Subscribe on .NET server
nats sub -s nats://localhost:4223 "test.>"
# Publish to both and compare
nats pub -s nats://localhost:4222 test.hello "payload"
nats pub -s nats://localhost:4223 test.hello "payload"
```
Document any behavioral differences. Some differences are expected (e.g., server name, version string) while others indicate bugs.
### Step 9: Final verification
Run the complete check:
```bash
# Phase 7 check — all tests verified
dotnet run --project tools/NatsNet.PortTracker -- phase check 7 --db porting.db
# Final summary — all items should be verified or n_a
dotnet run --project tools/NatsNet.PortTracker -- report summary --db porting.db
# Export final report
dotnet run --project tools/NatsNet.PortTracker -- report export \
--format md \
--output porting-final-report.md \
--db porting.db
```
## Troubleshooting
### Test passes individually but fails in module run
Likely a test ordering dependency or shared state. Check for:
- Static mutable state not reset between tests
- Port conflicts if tests start servers
- File system artifacts from previous test runs
Fix by adding proper test cleanup (`IDisposable`, `IAsyncLifetime`) and using unique ports/paths per test.
### Module passes but integration test fails
The issue is at a module boundary. Check:
- Interface implementations match expectations
- Serialization/deserialization is consistent across modules
- Thread safety at module interaction points
- Async patterns are correct (no fire-and-forget `Task` without error handling)
### Behavioral difference with Go server
1. Identify the specific protocol message or state that differs
2. Trace through both implementations step by step
3. Check the NATS protocol specification for the correct behavior
4. Fix the .NET implementation to match (the Go server is the reference)
## Completion Criteria
- [ ] [#45](https://gitea.dohertylan.com/dohertj2/natsnet/issues/45) All non-N/A modules have status `verified`
- [ ] [#46](https://gitea.dohertylan.com/dohertj2/natsnet/issues/46) All non-N/A features have status `verified`
- [ ] [#47](https://gitea.dohertylan.com/dohertj2/natsnet/issues/47) All non-N/A tests have status `verified`
- [ ] [#48](https://gitea.dohertylan.com/dohertj2/natsnet/issues/48) All targeted tests pass: `dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.Tests/`
- [ ] [#49](https://gitea.dohertylan.com/dohertj2/natsnet/issues/49) All integration tests pass: `dotnet test dotnet/tests/ZB.MOM.NatsNet.Server.IntegrationTests/`
- [ ] [#50](https://gitea.dohertylan.com/dohertj2/natsnet/issues/50) Key behavioral scenarios produce equivalent results on Go and .NET servers
- [ ] [#51](https://gitea.dohertylan.com/dohertj2/natsnet/issues/51) `phase check 7` passes with no errors
- [ ] [#52](https://gitea.dohertylan.com/dohertj2/natsnet/issues/52) Final report exported and reviewed
- [ ] Close the Phase 7 milestone in Gitea:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/milestones/7" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close it via the Gitea web UI at https://gitea.dohertylan.com/dohertj2/natsnet/milestone/7
- [ ] Close the **Final: Porting Complete** milestone in Gitea:
```bash
curl -s -X PATCH "https://gitea.dohertylan.com/api/v1/repos/dohertj2/natsnet/milestones/8" \
-H "Content-Type: application/json" \
-H "Authorization: token $GITEA_TOKEN" \
-d '{"state":"closed"}'
```
Or close it via the Gitea web UI at https://gitea.dohertylan.com/dohertj2/natsnet/milestone/8
## Related Documentation
- [Phase 6: Porting](phase-6-porting.md) -- the implementation phase this verifies
- [Phase 4: .NET Solution Design](phase-4-dotnet-design.md) -- the original design mappings

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 12:05:58 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| not_started | 12 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| not_started | 3673 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| not_started | 3257 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| not_mapped | 36 |
## Overall Progress
**0/6942 items complete (0.0%)**

View File

@@ -0,0 +1,32 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 12:13:14 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| not_started | 12 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| not_started | 3673 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| not_started | 3257 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| not_mapped | 36 |
## Overall Progress
**0/6942 items complete (0.0%)**

View File

@@ -0,0 +1,32 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 12:17:42 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| not_started | 12 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| not_started | 3673 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| not_started | 3257 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**0/6942 items complete (0.0%)**

View File

@@ -0,0 +1,33 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 12:42:13 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| not_started | 12 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| n_a | 41 |
| not_started | 3632 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| not_started | 3257 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**41/6942 items complete (0.6%)**

View File

@@ -0,0 +1,230 @@
# .NET Coding Standards
These standards apply to all .NET code in the `dotnet/` directory. All contributors and AI agents must follow these rules.
## Runtime and Language
- **Target framework**: .NET 10
- **Language**: C# (latest stable version)
- **Nullable reference types**: Enabled project-wide (`<Nullable>enable</Nullable>`)
- **Implicit usings**: Enabled (`<ImplicitUsings>enable</ImplicitUsings>`)
## General Practices
- Follow the [Microsoft C# coding conventions](https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions)
- Use `PascalCase` for public members, types, namespaces, and methods
- Use `camelCase` for local variables and parameters
- Prefix private fields with `_` (e.g., `_connectionCount`)
- Prefer `readonly` fields and immutable types where practical
- Use file-scoped namespaces
- Use primary constructors where they simplify the code
- Prefer pattern matching over type-checking casts
- Use `CancellationToken` on all async method signatures
- Use `ReadOnlySpan<byte>` and `ReadOnlyMemory<byte>` on hot paths to minimize allocations
- Prefer `ValueTask` over `Task` for methods that frequently complete synchronously
## Forbidden Packages
Do **NOT** use the following packages anywhere in the solution:
| Package | Reason |
|---------|--------|
| `FluentAssertions` | Use Shouldly instead |
| `Moq` | Use NSubstitute instead |
## Unit Testing
All unit tests live in `dotnet/tests/ZB.MOM.NatsNet.Server.Tests/`.
### Framework and Libraries
| Purpose | Package | Version |
|---------|---------|---------|
| Test framework | `xUnit` | 3.x |
| Assertions | `Shouldly` | latest |
| Mocking | `NSubstitute` | latest |
| Benchmarking | `BenchmarkDotNet` | latest (for `Benchmark*` ports) |
### xUnit 3 Conventions
- Use `[Fact]` for single-case tests
- Use `[Theory]` with `[InlineData]` or `[MemberData]` for parameterized tests (replaces Go table-driven tests)
- Use `[Collection]` to control test parallelism when tests share resources
- Test classes implement `IAsyncLifetime` when setup/teardown is async
- Do **not** use `[SetUp]` or `[TearDown]` — those are NUnit/MSTest concepts
### Shouldly Conventions
```csharp
// Preferred assertion style
result.ShouldBe(expected);
result.ShouldNotBeNull();
result.ShouldBeGreaterThan(0);
collection.ShouldContain(item);
collection.ShouldBeEmpty();
Should.Throw<InvalidOperationException>(() => subject.DoSomething());
await Should.ThrowAsync<TimeoutException>(async () => await subject.DoSomethingAsync());
```
### NSubstitute Conventions
```csharp
// Create substitutes
var logger = Substitute.For<ILogger<MyService>>();
var repository = Substitute.For<IRepository>();
// Configure returns
repository.GetByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new Entity { Id = 1 });
// Verify calls
logger.Received(1).Log(
Arg.Is(LogLevel.Warning),
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception?>(),
Arg.Any<Func<object, Exception?, string>>());
```
### Test Naming
```
[Method]_[Scenario]_[Expected]
```
Examples:
- `TryParse_ValidInput_ReturnsTrue`
- `Match_WildcardSubject_ReturnsSubscribers`
- `Connect_InvalidCredentials_ThrowsAuthException`
### Test Class Naming
```
[ClassName]Tests
```
Examples: `NatsParserTests`, `SubListTests`, `JetStreamControllerTests`
## Logging
Use `Microsoft.Extensions.Logging` with Serilog as the provider.
### Setup (in `ZB.MOM.NatsNet.Server.Host`)
```csharp
builder.Host.UseSerilog((context, services, configuration) =>
configuration.ReadFrom.Configuration(context.Configuration));
```
### Usage in Services
Inject `ILogger<T>` via constructor injection:
```csharp
public class ConnectionHandler
{
private readonly ILogger<ConnectionHandler> _logger;
public ConnectionHandler(ILogger<ConnectionHandler> logger)
{
_logger = logger;
}
public void HandleConnection(string clientId)
{
using (LogContext.PushProperty("ClientId", clientId))
{
_logger.LogInformation("Client connected");
}
}
}
```
### Structured Logging Rules
- **Always use message templates** with named placeholders — never string interpolation:
```csharp
// Correct
_logger.LogInformation("Client {ClientId} subscribed to {Subject}", clientId, subject);
// Wrong — loses structured data
_logger.LogInformation($"Client {clientId} subscribed to {subject}");
```
- **Use `LogContext.PushProperty`** to add contextual properties that apply to a scope of operations (e.g., client ID, connection ID, stream name). This enriches all log entries within the `using` block without repeating parameters:
```csharp
using (LogContext.PushProperty("ConnectionId", connection.Id))
using (LogContext.PushProperty("RemoteAddress", connection.RemoteEndPoint))
{
// All log entries here automatically include ConnectionId and RemoteAddress
_logger.LogDebug("Processing command");
_logger.LogInformation("Subscription created for {Subject}", subject);
}
```
- **Use appropriate log levels**:
| Level | Use for |
|-------|---------|
| `Trace` | Wire protocol bytes, parser state transitions |
| `Debug` | Internal state changes, subscription matching details |
| `Information` | Client connects/disconnects, server start/stop, config loaded |
| `Warning` | Slow consumers, approaching limits, recoverable errors |
| `Error` | Failed operations, unhandled protocol errors |
| `Critical` | Server cannot continue, data corruption detected |
### Serilog Configuration
Configure via `appsettings.json`:
```json
{
"Serilog": {
"Using": ["Serilog.Sinks.Console"],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"Enrich": ["FromLogContext"],
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
}
]
}
}
```
The `"Enrich": ["FromLogContext"]` entry is required for `LogContext.PushProperty` to work.
## Dependency Injection
- Register services in `ZB.MOM.NatsNet.Server.Host` using `Microsoft.Extensions.DependencyInjection`
- Prefer constructor injection
- Use `IOptions<T>` / `IOptionsMonitor<T>` for configuration binding
- Register scoped services for per-connection lifetime, singletons for server-wide services
## Async Patterns
- All I/O operations must be async (`async`/`await`)
- Use `CancellationToken` propagation consistently
- Use `Channel<T>` for producer-consumer patterns (replaces Go channels)
- Use `Task.WhenAll` / `Task.WhenAny` for concurrent operations (replaces Go `select`)
- Avoid `Task.Run` for CPU-bound work in hot paths — prefer dedicated processing pipelines
## Performance
- Use `System.IO.Pipelines` (`PipeReader`/`PipeWriter`) for network I/O
- Prefer `Span<T>` / `Memory<T>` over arrays for buffer operations
- Use `ArrayPool<T>.Shared` for temporary buffers
- Use `ObjectPool<T>` for frequently allocated objects
- Profile before optimizing — do not prematurely optimize
## Related Documentation
- [Documentation Rules](../../documentation_rules.md)
- [Phase 4: .NET Solution Design](../plans/phases/phase-4-dotnet-design.md)
- [Phase 6: Porting](../plans/phases/phase-6-porting.md)

318
documentation_rules.md Normal file
View File

@@ -0,0 +1,318 @@
# Documentation Rules
This document defines the documentation system for the NATS .NET server project. It provides guidelines for generating, updating, and maintaining project documentation.
The documentation is intended for internal team reference — explaining what the system is, how it works, how to extend it, and how to debug it.
## Folder Structure
```
Documentation/
├── Instructions/ # Guidelines for LLMs (meta-documentation)
│ └── (this file serves as the single instructions reference)
├── GettingStarted/ # Onboarding, prerequisites, first run
├── Protocol/ # Wire protocol, parser, command types
├── Subscriptions/ # SubList trie, subject matching, wildcards
├── Server/ # NatsServer orchestrator, NatsClient handler
├── Configuration/ # NatsOptions, appsettings, CLI arguments
├── Operations/ # Deployment, monitoring, health checks, troubleshooting
└── Plans/ # Design documents and implementation plans
```
Future module folders (add as modules are ported):
```
├── Authentication/ # Auth mechanisms, NKeys, JWT, accounts
├── Clustering/ # Routes, gateways, leaf nodes
├── JetStream/ # Streams, consumers, storage, RAFT
├── Monitoring/ # HTTP endpoints (/varz, /connz, etc.)
├── WebSocket/ # WebSocket transport
└── TLS/ # TLS configuration and setup
```
---
## Style Guide
### Tone and Voice
- **Technical and direct** — no marketing language. Avoid "powerful", "robust", "seamless", "blazing fast".
- **Assume the reader is a .NET developer** — don't explain dependency injection, async/await, or LINQ basics.
- **Explain "why" not just "what"** — document reasoning behind patterns and decisions.
- **Use present tense** — "The parser reads..." not "The parser will read..."
### Formatting Rules
| Aspect | Convention |
|--------|------------|
| File names | `PascalCase.md` |
| H1 (`#`) | Document title only, Title Case |
| H2 (`##`) | Major sections, Title Case |
| H3+ (`###`) | Subsections, Sentence case |
| Code blocks | Always specify language (`csharp`, `json`, `bash`, `xml`) |
| Code snippets | 5-25 lines typical; include class/method context |
| Cross-references | Relative paths: `[See SubList](../Subscriptions/SubList.md)` |
| Inline code | Backticks for code refs: `NatsServer`, `SubList.Match()`, `NatsOptions` |
| Lists | Bullets for unordered, numbers for sequential steps |
| Tables | For structured reference (config options, command formats) |
### Naming Conventions
- Match code terminology exactly: `SubList` not "Subject List", `NatsClient` not "NATS Client Handler"
- Use backticks for all code references: `NatsParser`, `appsettings.json`, `dotnet test`
- Spell out acronyms on first use: "NATS Adaptive Edge Messaging (NATS)" — common acronyms that don't need expansion: API, JSON, TCP, HTTP, TLS, JWT
### Code Snippet Guidelines
**Do:**
- Copy snippets from actual source files
- Include enough context (class name, method signature)
- Specify the language in code blocks
- Show 5-25 line examples
```csharp
// Good — shows class context
public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
{
public async Task StartAsync(CancellationToken ct)
{
_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_listener.Bind(new IPEndPoint(IPAddress.Parse(_options.Host), _options.Port));
_listener.Listen(128);
}
}
```
**Don't:**
- Invent example code that doesn't exist in the codebase
- Include 100+ line dumps without explanation
- Use pseudocode when real code is available
- Omit the language specifier on code blocks
### Structure Conventions
Every documentation file must include:
1. **Title and purpose** — H1 heading with 1-2 sentence description
2. **Key concepts** — if the topic requires background understanding
3. **Code examples** — embedded snippets from actual codebase
4. **Configuration** — if the component has configurable options
5. **Related documentation** — links to related topics
Organize content from general to specific:
1. Overview/introduction
2. Key concepts
3. Basic usage
4. Advanced usage / internals
5. Configuration
6. Troubleshooting
7. Related documentation
End each document with:
```markdown
## Related Documentation
- [Related Topic](../Component/Topic.md)
```
### What to Avoid
- Don't document the obvious (e.g., "The constructor creates a new instance")
- Don't duplicate source code comments — reference the file instead
- Don't include temporary information (dates, version numbers, "coming soon")
- Don't over-explain .NET basics
---
## Generating Documentation
### Document Types
Each component folder should contain these standard files:
| File | Purpose |
|------|---------|
| `Overview.md` | What the component does, key concepts, architecture |
| `Development.md` | How to add/modify features, patterns to follow |
| `Configuration.md` | All configurable options with defaults and examples |
| `Troubleshooting.md` | Common issues, error messages, debugging steps |
Create additional topic-specific files as needed (e.g., `Protocol/Parser.md`, `Subscriptions/SubList.md`).
### Generation Process
1. **Identify scope** — which component folder does this belong to? (See Component Map below)
2. **Read source code** — understand the current implementation, identify key classes/methods/patterns, note configuration options
3. **Check existing documentation** — avoid duplication, cross-reference rather than repeat
4. **Write documentation** — follow the style guide, use real code snippets
5. **Verify accuracy** — confirm snippets match source, verify file paths and class names, test commands
### Creating New Component Folders
1. Create the folder under `Documentation/`
2. Add at minimum `Overview.md`
3. Add other standard files as content warrants
4. Update the Component Map section below
5. Add cross-references from related documentation
---
## Updating Documentation
### Update Triggers
| Code Change | Update These Docs |
|-------------|-------------------|
| New protocol command | `Protocol/` relevant file |
| Parser modified | `Protocol/Parser.md` |
| Subject matching changed | `Subscriptions/SubjectMatch.md` |
| SubList trie modified | `Subscriptions/SubList.md` |
| New subscription type | `Subscriptions/Overview.md` |
| NatsServer changed | `Server/Overview.md` |
| NatsClient changed | `Server/Client.md` |
| Config option added/removed | Component's `Configuration.md` |
| NatsOptions changed | `Configuration/Overview.md` |
| Host startup changed | `Operations/Deployment.md` + `Configuration/` |
| New test patterns | Corresponding component docs |
| Auth mechanism added | `Authentication/` (create if needed) |
| Clustering added | `Clustering/` (create if needed) |
### Update Process
1. **Identify affected documentation** — use the Component Map to determine which docs need updating
2. **Read current documentation** — understand existing structure before making changes
3. **Make targeted updates** — only modify sections affected by the code change; don't rewrite unaffected sections
4. **Update code snippets** — if the code change affects documented examples, update them to match
5. **Update cross-references** — add links to newly related docs, remove links to deleted content
6. **Add verification comment** — at the bottom: `<!-- Last verified against codebase: YYYY-MM-DD -->`
### Deletion Handling
- When code is removed, remove corresponding doc sections
- When code is renamed, update all references (docs, snippets, cross-reference links)
- If an entire feature is removed, delete the doc file and update any index/overview docs
- Search all docs for links to removed content
### What Not to Update
- Don't reformat documentation that wasn't affected by the code change
- Don't update examples that still work correctly
- Don't add new content unrelated to the code change
- Don't change writing style in unaffected sections
---
## Component Map
### Source to Documentation Mapping
| Source Path | Documentation Folder |
|-------------|---------------------|
| `src/NATS.Server/Protocol/NatsParser.cs` | `Protocol/` |
| `src/NATS.Server/Protocol/NatsProtocol.cs` | `Protocol/` |
| `src/NATS.Server/Subscriptions/SubList.cs` | `Subscriptions/` |
| `src/NATS.Server/Subscriptions/SubjectMatch.cs` | `Subscriptions/` |
| `src/NATS.Server/Subscriptions/Subscription.cs` | `Subscriptions/` |
| `src/NATS.Server/Subscriptions/SubListResult.cs` | `Subscriptions/` |
| `src/NATS.Server/NatsServer.cs` | `Server/` |
| `src/NATS.Server/NatsClient.cs` | `Server/` |
| `src/NATS.Server/NatsOptions.cs` | `Configuration/` |
| `src/NATS.Server.Host/Program.cs` | `Operations/` and `Configuration/` |
| `tests/NATS.Server.Tests/` | Document in corresponding component |
| `golang/nats-server/server/` | Reference material (not documented separately) |
### Component Details
#### Protocol/
Documents the wire protocol and parser.
**Source paths:**
- `src/NATS.Server/Protocol/NatsParser.cs` — state machine parser
- `src/NATS.Server/Protocol/NatsProtocol.cs` — constants, ServerInfo, ClientOptions
**Typical files:**
- `Overview.md` — NATS protocol format, command types, wire format
- `Parser.md` — Parser implementation, `TryParse` flow, state machine
- `Commands.md` — Individual command formats (PUB, SUB, UNSUB, MSG, etc.)
#### Subscriptions/
Documents subject matching and the subscription trie.
**Source paths:**
- `src/NATS.Server/Subscriptions/SubList.cs` — trie + cache
- `src/NATS.Server/Subscriptions/SubjectMatch.cs` — validation and wildcard matching
- `src/NATS.Server/Subscriptions/Subscription.cs` — subscription model
- `src/NATS.Server/Subscriptions/SubListResult.cs` — match result container
**Typical files:**
- `Overview.md` — Subject namespace, wildcard rules, queue groups
- `SubList.md` — Trie internals, cache invalidation, thread safety
- `SubjectMatch.md` — Validation rules, wildcard matching algorithm
#### Server/
Documents the server orchestrator and client connection handler.
**Source paths:**
- `src/NATS.Server/NatsServer.cs` — accept loop, message routing
- `src/NATS.Server/NatsClient.cs` — per-connection read/write, subscription tracking
**Typical files:**
- `Overview.md` — Server architecture, connection lifecycle, message flow
- `Client.md` — Client connection handling, command dispatch, write serialization
- `MessageRouting.md` — How messages flow from PUB to subscribers
#### Configuration/
Documents server configuration options.
**Source paths:**
- `src/NATS.Server/NatsOptions.cs` — configuration model
- `src/NATS.Server.Host/Program.cs` — CLI argument parsing, Serilog setup
**Typical files:**
- `Overview.md` — All options with defaults and descriptions
- `Logging.md` — Serilog configuration, log levels, LogContext usage
#### Operations/
Documents deployment and operational concerns.
**Source paths:**
- `src/NATS.Server.Host/` — host application
**Typical files:**
- `Overview.md` — Running the server, CLI arguments
- `Deployment.md` — Deployment procedures
- `Troubleshooting.md` — Common issues and debugging
#### GettingStarted/
Documents onboarding and project overview.
**Typical files:**
- `Setup.md` — Prerequisites, building, running
- `Architecture.md` — System overview, Go reference mapping
- `Development.md` — Development workflow, testing, contributing
### Ambiguous Cases
| Code Type | Document In |
|-----------|-------------|
| Logging setup | `Configuration/Logging.md` |
| Integration tests | `Operations/Testing.md` or corresponding component |
| Shared interfaces (`IMessageRouter`, `ISubListAccess`) | `Server/Overview.md` |
| Go reference code | Don't document separately; reference in `.NET` component docs |
### Adding New Components
When a new module is ported (Authentication, Clustering, JetStream, etc.):
1. Create a new folder under `Documentation/`
2. Add at minimum `Overview.md`
3. Add this mapping table entry
4. Update CLAUDE.md documentation index if it has one
5. Cross-reference from related component docs

View File

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

BIN
dotnet/porting.db Normal file

Binary file not shown.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,525 @@
// Copyright 2018-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/accounts.go in the NATS server Go source.
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// IAccountResolver
// Mirrors Go AccountResolver interface (accounts.go ~line 4035).
// ============================================================================
/// <summary>
/// Resolves and stores account JWTs by account public key name.
/// Mirrors Go <c>AccountResolver</c> interface.
/// </summary>
public interface IAccountResolver
{
/// <summary>
/// Fetches the JWT for the named account.
/// Throws <see cref="InvalidOperationException"/> when the account is not found.
/// Mirrors Go <c>AccountResolver.Fetch</c>.
/// </summary>
Task<string> FetchAsync(string name, CancellationToken ct = default);
/// <summary>
/// Stores the JWT for the named account.
/// Read-only implementations throw <see cref="NotSupportedException"/>.
/// Mirrors Go <c>AccountResolver.Store</c>.
/// </summary>
Task StoreAsync(string name, string jwt, CancellationToken ct = default);
/// <summary>Returns true when no writes are permitted. Mirrors Go <c>IsReadOnly</c>.</summary>
bool IsReadOnly();
/// <summary>
/// Starts any background processing needed by the resolver (system subscriptions, timers, etc.).
/// The <paramref name="server"/> parameter accepts an <c>object</c> to avoid a circular assembly
/// reference; implementations should cast it to the concrete server type as needed.
/// Mirrors Go <c>AccountResolver.Start</c>.
/// </summary>
void Start(object server);
/// <summary>Returns true when the resolver reacts to JWT update events. Mirrors Go <c>IsTrackingUpdate</c>.</summary>
bool IsTrackingUpdate();
/// <summary>Reloads state from the backing store. Mirrors Go <c>AccountResolver.Reload</c>.</summary>
void Reload();
/// <summary>Releases resources held by the resolver. Mirrors Go <c>AccountResolver.Close</c>.</summary>
void Close();
}
// ============================================================================
// ResolverDefaultsOps
// Mirrors Go resolverDefaultsOpsImpl (accounts.go ~line 4046).
// ============================================================================
/// <summary>
/// Abstract base that provides sensible no-op / read-only defaults for <see cref="IAccountResolver"/>
/// so concrete implementations only need to override what they change.
/// Mirrors Go <c>resolverDefaultsOpsImpl</c>.
/// </summary>
public abstract class ResolverDefaultsOps : IAccountResolver
{
/// <inheritdoc/>
public abstract Task<string> FetchAsync(string name, CancellationToken ct = default);
/// <summary>
/// Default store implementation — always throws because the base defaults to read-only.
/// Mirrors Go <c>resolverDefaultsOpsImpl.Store</c>.
/// </summary>
public virtual Task StoreAsync(string name, string jwt, CancellationToken ct = default)
=> throw new NotSupportedException("store operation not supported");
/// <summary>Default: the resolver is read-only. Mirrors Go <c>resolverDefaultsOpsImpl.IsReadOnly</c>.</summary>
public virtual bool IsReadOnly() => true;
/// <summary>Default: no-op start. Mirrors Go <c>resolverDefaultsOpsImpl.Start</c>.</summary>
public virtual void Start(object server) { }
/// <summary>Default: does not track updates. Mirrors Go <c>resolverDefaultsOpsImpl.IsTrackingUpdate</c>.</summary>
public virtual bool IsTrackingUpdate() => false;
/// <summary>Default: no-op reload. Mirrors Go <c>resolverDefaultsOpsImpl.Reload</c>.</summary>
public virtual void Reload() { }
/// <summary>Default: no-op close. Mirrors Go <c>resolverDefaultsOpsImpl.Close</c>.</summary>
public virtual void Close() { }
}
// ============================================================================
// MemoryAccountResolver
// Mirrors Go MemAccResolver (accounts.go ~line 4072).
// ============================================================================
/// <summary>
/// An in-memory account resolver backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/>.
/// Primarily intended for testing.
/// Mirrors Go <c>MemAccResolver</c>.
/// </summary>
public sealed class MemoryAccountResolver : ResolverDefaultsOps
{
private readonly ConcurrentDictionary<string, string> _store = new(StringComparer.Ordinal);
/// <summary>In-memory resolver is not read-only.</summary>
public override bool IsReadOnly() => false;
/// <summary>
/// Returns the stored JWT for <paramref name="name"/>, or throws
/// <see cref="InvalidOperationException"/> when the account is unknown.
/// Mirrors Go <c>MemAccResolver.Fetch</c>.
/// </summary>
public override Task<string> FetchAsync(string name, CancellationToken ct = default)
{
if (_store.TryGetValue(name, out var jwt))
{
return Task.FromResult(jwt);
}
throw new InvalidOperationException($"Account not found: {name}");
}
/// <summary>
/// Stores <paramref name="jwt"/> for <paramref name="name"/>.
/// Mirrors Go <c>MemAccResolver.Store</c>.
/// </summary>
public override Task StoreAsync(string name, string jwt, CancellationToken ct = default)
{
_store[name] = jwt;
return Task.CompletedTask;
}
}
// ============================================================================
// UrlAccountResolver
// Mirrors Go URLAccResolver (accounts.go ~line 4097).
// ============================================================================
/// <summary>
/// An HTTP-based account resolver that fetches JWTs by appending the account public key
/// to a configured base URL.
/// Mirrors Go <c>URLAccResolver</c>.
/// </summary>
public sealed class UrlAccountResolver : ResolverDefaultsOps
{
// Mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT.
private static readonly TimeSpan DefaultAccountFetchTimeout = TimeSpan.FromSeconds(2);
private readonly string _url;
private readonly HttpClient _httpClient;
/// <summary>
/// Creates a new URL resolver for the given <paramref name="url"/>.
/// A trailing slash is appended when absent so that account names can be concatenated
/// directly. An <see cref="HttpClient"/> is configured with connection-pooling
/// settings that amortise TLS handshakes across requests, mirroring Go's custom
/// <c>http.Transport</c>.
/// Mirrors Go <c>NewURLAccResolver</c>.
/// </summary>
public UrlAccountResolver(string url)
{
if (!url.EndsWith('/'))
{
url += "/";
}
_url = url;
// Mirror Go: MaxIdleConns=10, IdleConnTimeout=30s on a custom transport.
var handler = new SocketsHttpHandler
{
MaxConnectionsPerServer = 10,
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
};
_httpClient = new HttpClient(handler)
{
Timeout = DefaultAccountFetchTimeout,
};
}
/// <summary>
/// Issues an HTTP GET to the base URL with the account name appended, and returns
/// the response body as the JWT string.
/// Throws <see cref="InvalidOperationException"/> on a non-200 response.
/// Mirrors Go <c>URLAccResolver.Fetch</c>.
/// </summary>
public override async Task<string> FetchAsync(string name, CancellationToken ct = default)
{
var requestUrl = _url + name;
HttpResponseMessage response;
try
{
response = await _httpClient.GetAsync(requestUrl, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
throw new InvalidOperationException($"could not fetch <\"{requestUrl}\">: {ex.Message}", ex);
}
using (response)
{
if (response.StatusCode != HttpStatusCode.OK)
{
throw new InvalidOperationException(
$"could not fetch <\"{requestUrl}\">: {(int)response.StatusCode} {response.ReasonPhrase}");
}
return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
}
}
// ============================================================================
// DirResOption — functional option for DirAccountResolver
// Mirrors Go DirResOption func type (accounts.go ~line 4552).
// ============================================================================
/// <summary>
/// A functional option that configures a <see cref="DirAccountResolver"/> instance.
/// Mirrors Go <c>DirResOption</c> function type.
/// </summary>
public delegate void DirResOption(DirAccountResolver resolver);
/// <summary>
/// Factory methods for commonly used <see cref="DirResOption"/> values.
/// </summary>
public static class DirResOptions
{
/// <summary>
/// Returns an option that overrides the default fetch timeout.
/// <paramref name="timeout"/> must be positive.
/// Mirrors Go <c>FetchTimeout</c> option constructor.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown at application time when <paramref name="timeout"/> is not positive.
/// </exception>
public static DirResOption FetchTimeout(TimeSpan timeout)
{
if (timeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(timeout),
$"Fetch timeout {timeout} is too small");
}
return resolver => resolver.FetchTimeout = timeout;
}
}
// ============================================================================
// DirAccountResolver (stub)
// Mirrors Go DirAccResolver (accounts.go ~line 4143).
// Full system-subscription wiring is deferred to session 12.
// ============================================================================
/// <summary>
/// A directory-backed account resolver that stores JWTs in a <see cref="DirJwtStore"/>
/// and synchronises with peers via NATS system subjects.
/// <para>
/// The Start override that wires up system subscriptions and the periodic sync goroutine
/// is a stub in this session; full implementation requires JetStream and system
/// subscription support (session 12+).
/// </para>
/// Mirrors Go <c>DirAccResolver</c>.
/// </summary>
public class DirAccountResolver : ResolverDefaultsOps, IDisposable
{
// Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s).
private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2);
// Default sync interval — mirrors Go's fallback of 1 minute.
private static readonly TimeSpan DefaultSyncInterval = TimeSpan.FromMinutes(1);
/// <summary>The underlying directory JWT store. Mirrors Go <c>DirAccResolver.DirJWTStore</c>.</summary>
public DirJwtStore Store { get; }
/// <summary>Reference to the running server, set during <see cref="Start"/>. Mirrors Go <c>DirAccResolver.Server</c>.</summary>
public object? Server { get; protected set; }
/// <summary>How often the resolver sends a sync (pack) request to peers. Mirrors Go <c>DirAccResolver.syncInterval</c>.</summary>
public TimeSpan SyncInterval { get; protected set; }
/// <summary>Maximum time to wait for a remote JWT fetch. Mirrors Go <c>DirAccResolver.fetchTimeout</c>.</summary>
public TimeSpan FetchTimeout { get; set; }
/// <summary>
/// Creates a new directory account resolver.
/// <para>
/// When <paramref name="limit"/> is zero it is promoted to <see cref="long.MaxValue"/> (unlimited).
/// When <paramref name="syncInterval"/> is non-positive it defaults to one minute.
/// </para>
/// Mirrors Go <c>NewDirAccResolver</c>.
/// </summary>
/// <param name="path">Directory path for the JWT store.</param>
/// <param name="limit">Maximum number of JWTs the store may hold (0 = unlimited).</param>
/// <param name="syncInterval">How often to broadcast a sync/pack request to peers.</param>
/// <param name="deleteType">Controls whether deletes are soft- or hard-deleted.</param>
/// <param name="opts">Zero or more functional options to further configure this instance.</param>
public DirAccountResolver(
string path,
long limit,
TimeSpan syncInterval,
JwtDeleteType deleteType,
params DirResOption[] opts)
{
if (limit == 0)
{
limit = long.MaxValue;
}
if (syncInterval <= TimeSpan.Zero)
{
syncInterval = DefaultSyncInterval;
}
Store = DirJwtStore.NewExpiringDirJwtStore(
path,
shard: false,
create: true,
deleteType,
expireCheck: TimeSpan.Zero,
limit,
evictOnLimit: false,
ttl: TimeSpan.Zero,
changeNotification: null);
SyncInterval = syncInterval;
FetchTimeout = DefaultFetchTimeout;
Apply(opts);
}
// Internal constructor used by CacheDirAccountResolver which supplies its own store.
internal DirAccountResolver(
DirJwtStore store,
TimeSpan syncInterval,
TimeSpan fetchTimeout)
{
Store = store;
SyncInterval = syncInterval;
FetchTimeout = fetchTimeout;
}
/// <summary>
/// Applies a sequence of functional options to this resolver.
/// Mirrors Go <c>DirAccResolver.apply</c>.
/// </summary>
protected void Apply(IEnumerable<DirResOption> opts)
{
foreach (var opt in opts)
{
opt(this);
}
}
// -------------------------------------------------------------------------
// IAccountResolver overrides
// -------------------------------------------------------------------------
/// <summary>
/// DirAccountResolver is not read-only.
/// Mirrors Go: DirAccResolver does not override IsReadOnly, so it inherits false
/// from the concrete behaviour (store is writable).
/// </summary>
public override bool IsReadOnly() => false;
/// <summary>
/// Tracks updates (reacts to JWT change events).
/// Mirrors Go <c>DirAccResolver.IsTrackingUpdate</c>.
/// </summary>
public override bool IsTrackingUpdate() => true;
/// <summary>
/// Reloads state from the backing <see cref="DirJwtStore"/>.
/// Mirrors Go <c>DirAccResolver.Reload</c>.
/// </summary>
public override void Reload() => Store.Reload();
/// <summary>
/// Fetches the JWT for <paramref name="name"/> from the local <see cref="DirJwtStore"/>.
/// Throws <see cref="InvalidOperationException"/> when the account is not found locally.
/// <para>
/// Note: the Go implementation falls back to <c>srv.fetch</c> (a cluster-wide lookup) when
/// the local store misses. That fallback requires system subscriptions and is deferred to
/// session 12. For now this method only consults the local store.
/// </para>
/// Mirrors Go <c>DirAccResolver.Fetch</c> (local path only).
/// </summary>
public override Task<string> FetchAsync(string name, CancellationToken ct = default)
{
var theJwt = Store.LoadAcc(name);
if (!string.IsNullOrEmpty(theJwt))
{
return Task.FromResult(theJwt);
}
throw new InvalidOperationException($"Account not found: {name}");
}
/// <summary>
/// Stores <paramref name="jwt"/> under <paramref name="name"/>, keeping the newer JWT
/// when a conflicting entry already exists.
/// Mirrors Go <c>DirAccResolver.Store</c> (delegates to <c>saveIfNewer</c>).
/// </summary>
public override Task StoreAsync(string name, string jwt, CancellationToken ct = default)
{
// SaveAcc is equivalent to saveIfNewer in the DirJwtStore implementation.
Store.SaveAcc(name, jwt);
return Task.CompletedTask;
}
/// <summary>
/// Starts background system subscriptions and the periodic sync timer.
/// <para>
/// TODO (session 12): wire up system subscriptions for account JWT update/lookup/pack
/// requests, cluster synchronisation, and the periodic pack broadcast goroutine.
/// </para>
/// Mirrors Go <c>DirAccResolver.Start</c>.
/// </summary>
public override void Start(object server)
{
Server = server;
// TODO (session 12): set up system subscriptions and periodic sync timer.
}
/// <summary>
/// Stops background processing and closes the <see cref="DirJwtStore"/>.
/// Mirrors Go <c>AccountResolver.Close</c> (no explicit Go override; store is closed
/// by the server shutdown path).
/// </summary>
public override void Close() => Store.Close();
/// <inheritdoc/>
public void Dispose() => Store.Dispose();
}
// ============================================================================
// CacheDirAccountResolver (stub)
// Mirrors Go CacheDirAccResolver (accounts.go ~line 4594).
// ============================================================================
/// <summary>
/// A caching variant of <see cref="DirAccountResolver"/> that uses a TTL-based expiring
/// store so that fetched JWTs are automatically evicted after <see cref="Ttl"/>.
/// <para>
/// The Start override that wires up system subscriptions is a stub in this session;
/// full implementation requires system subscription support (session 12+).
/// </para>
/// Mirrors Go <c>CacheDirAccResolver</c>.
/// </summary>
public sealed class CacheDirAccountResolver : DirAccountResolver
{
// Default cache limit — mirrors Go's fallback of 1 000 entries.
private const long DefaultCacheLimit = 1_000;
// Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s).
private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2);
/// <summary>The TTL applied to each cached JWT entry. Mirrors Go <c>CacheDirAccResolver.ttl</c>.</summary>
public TimeSpan Ttl { get; }
/// <summary>
/// Creates a new caching directory account resolver.
/// <para>
/// When <paramref name="limit"/> is zero or negative it defaults to 1 000.
/// </para>
/// Mirrors Go <c>NewCacheDirAccResolver</c>.
/// </summary>
/// <param name="path">Directory path for the JWT store.</param>
/// <param name="limit">Maximum number of JWTs to cache (0 = 1 000).</param>
/// <param name="ttl">Time-to-live for each cached JWT.</param>
/// <param name="opts">Zero or more functional options to further configure this instance.</param>
public CacheDirAccountResolver(
string path,
long limit,
TimeSpan ttl,
params DirResOption[] opts)
: base(
store: DirJwtStore.NewExpiringDirJwtStore(
path,
shard: false,
create: true,
JwtDeleteType.HardDelete,
expireCheck: TimeSpan.Zero,
limit: limit <= 0 ? DefaultCacheLimit : limit,
evictOnLimit: true,
ttl: ttl,
changeNotification: null),
syncInterval: TimeSpan.Zero,
fetchTimeout: DefaultFetchTimeout)
{
Ttl = ttl;
Apply(opts);
}
/// <summary>
/// Starts background system subscriptions for cached JWT update notifications.
/// <para>
/// TODO (session 12): wire up system subscriptions for account JWT update events
/// (cache variant — does not include pack/list/delete handling).
/// </para>
/// Mirrors Go <c>CacheDirAccResolver.Start</c>.
/// </summary>
public override void Start(object server)
{
Server = server;
// TODO (session 12): set up system subscriptions for cache-update notifications.
}
}

View File

@@ -0,0 +1,737 @@
// Copyright 2018-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/accounts.go in the NATS server Go source.
using System.Text.Json.Serialization;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// AccountLimits — account-based limits
// Mirrors Go `limits` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Per-account connection and payload limits.
/// Mirrors Go <c>limits</c> struct in server/accounts.go.
/// </summary>
internal sealed class AccountLimits
{
/// <summary>Maximum payload size (-1 = unlimited). Mirrors Go <c>mpay</c>.</summary>
public int MaxPayload { get; set; } = -1;
/// <summary>Maximum subscriptions (-1 = unlimited). Mirrors Go <c>msubs</c>.</summary>
public int MaxSubscriptions { get; set; } = -1;
/// <summary>Maximum connections (-1 = unlimited). Mirrors Go <c>mconns</c>.</summary>
public int MaxConnections { get; set; } = -1;
/// <summary>Maximum leaf nodes (-1 = unlimited). Mirrors Go <c>mleafs</c>.</summary>
public int MaxLeafNodes { get; set; } = -1;
/// <summary>When true, bearer tokens are not allowed. Mirrors Go <c>disallowBearer</c>.</summary>
public bool DisallowBearer { get; set; }
}
// ============================================================================
// SConns — remote server connection/leafnode counters
// Mirrors Go `sconns` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Tracks the number of client connections and leaf nodes for a remote server.
/// Mirrors Go <c>sconns</c> struct in server/accounts.go.
/// </summary>
internal sealed class SConns
{
/// <summary>Number of client connections from the remote server. Mirrors Go <c>conns</c>.</summary>
public int Conns;
/// <summary>Number of leaf nodes from the remote server. Mirrors Go <c>leafs</c>.</summary>
public int Leafs;
}
// ============================================================================
// ServiceRespType — service response type enum
// Mirrors Go `ServiceRespType` and its iota constants in server/accounts.go.
// ============================================================================
/// <summary>
/// The response type for an exported service.
/// Mirrors Go <c>ServiceRespType</c> in server/accounts.go.
/// </summary>
public enum ServiceRespType : byte
{
/// <summary>A single response is expected. Default. Mirrors Go <c>Singleton</c>.</summary>
Singleton = 0,
/// <summary>Multiple responses are streamed. Mirrors Go <c>Streamed</c>.</summary>
Streamed = 1,
/// <summary>Responses are sent in chunks. Mirrors Go <c>Chunked</c>.</summary>
Chunked = 2,
}
/// <summary>
/// Extension methods for <see cref="ServiceRespType"/>.
/// </summary>
public static class ServiceRespTypeExtensions
{
/// <summary>
/// Returns the string representation of the response type.
/// Mirrors Go <c>ServiceRespType.String()</c>.
/// </summary>
public static string ToNatsString(this ServiceRespType rt) => rt switch
{
ServiceRespType.Singleton => "Singleton",
ServiceRespType.Streamed => "Streamed",
ServiceRespType.Chunked => "Chunked",
_ => "Unknown ServiceResType",
};
}
// ============================================================================
// ExportAuth — export authorization configuration
// Mirrors Go `exportAuth` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Holds configured approvals or a flag indicating that an auth token is
/// required for import.
/// Mirrors Go <c>exportAuth</c> struct in server/accounts.go.
/// </summary>
internal class ExportAuth
{
/// <summary>When true, an auth token is required to import this export. Mirrors Go <c>tokenReq</c>.</summary>
public bool TokenRequired { get; set; }
/// <summary>
/// Position in the subject token where the account name appears (for
/// public exports that embed the importing account name).
/// Mirrors Go <c>accountPos</c>.
/// </summary>
public uint AccountPosition { get; set; }
/// <summary>
/// Accounts explicitly approved to import this export.
/// Key is the account name. Mirrors Go <c>approved</c>.
/// </summary>
public Dictionary<string, Account>? Approved { get; set; }
/// <summary>
/// Accounts whose activations have been revoked.
/// Key is the account name, value is the revocation timestamp (Unix ns).
/// Mirrors Go <c>actsRevoked</c>.
/// </summary>
public Dictionary<string, long>? ActivationsRevoked { get; set; }
}
// ============================================================================
// StreamExport — exported stream descriptor
// Mirrors Go `streamExport` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Describes a stream exported by an account.
/// Mirrors Go <c>streamExport</c> struct in server/accounts.go.
/// </summary>
internal sealed class StreamExport : ExportAuth
{
// No additional fields beyond ExportAuth for now.
// Full implementation in session 11 (accounts.go).
}
// ============================================================================
// InternalServiceLatency — service latency tracking configuration
// Mirrors Go `serviceLatency` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Configuration for service latency tracking on an exported service.
/// Mirrors Go <c>serviceLatency</c> struct in server/accounts.go.
/// </summary>
internal sealed class InternalServiceLatency
{
/// <summary>
/// Sampling percentage (1100), or 0 to indicate triggered by header.
/// Mirrors Go <c>sampling int8</c>.
/// </summary>
public int Sampling { get; set; }
/// <summary>Subject to publish latency metrics to. Mirrors Go <c>subject</c>.</summary>
public string Subject { get; set; } = string.Empty;
}
// ============================================================================
// ServiceExportEntry — exported service descriptor
// Mirrors Go `serviceExport` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Describes a service exported by an account with additional configuration
/// for response type, latency tracking, and timers.
/// Mirrors Go <c>serviceExport</c> struct in server/accounts.go.
/// </summary>
internal sealed class ServiceExportEntry : ExportAuth
{
/// <summary>Account that owns this export. Mirrors Go <c>acc</c>.</summary>
public Account? Account { get; set; }
/// <summary>Response type (Singleton, Streamed, Chunked). Mirrors Go <c>respType</c>.</summary>
public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton;
/// <summary>Latency tracking configuration, or null if disabled. Mirrors Go <c>latency</c>.</summary>
public InternalServiceLatency? Latency { get; set; }
/// <summary>
/// Timer used to collect response-latency measurements.
/// Mirrors Go <c>rtmr *time.Timer</c>.
/// </summary>
public Timer? ResponseTimer { get; set; }
/// <summary>
/// Threshold duration for service responses.
/// Mirrors Go <c>respThresh time.Duration</c>.
/// </summary>
public TimeSpan ResponseThreshold { get; set; }
/// <summary>
/// When true, tracing is allowed past the account boundary for this export.
/// Mirrors Go <c>atrc</c> (allow_trace).
/// </summary>
public bool AllowTrace { get; set; }
}
// ============================================================================
// ExportMap — tracks exported streams and services for an account
// Mirrors Go `exportMap` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Tracks all stream exports, service exports, and response mappings for an account.
/// Mirrors Go <c>exportMap</c> struct in server/accounts.go.
/// </summary>
internal sealed class ExportMap
{
/// <summary>
/// Exported streams keyed by subject pattern.
/// Mirrors Go <c>streams map[string]*streamExport</c>.
/// </summary>
public Dictionary<string, StreamExport>? Streams { get; set; }
/// <summary>
/// Exported services keyed by subject pattern.
/// Mirrors Go <c>services map[string]*serviceExport</c>.
/// </summary>
public Dictionary<string, ServiceExportEntry>? Services { get; set; }
/// <summary>
/// In-flight response service imports keyed by reply subject.
/// Mirrors Go <c>responses map[string]*serviceImport</c>.
/// </summary>
public Dictionary<string, ServiceImportEntry>? Responses { get; set; }
}
// ============================================================================
// ImportMap — tracks imported streams and services for an account
// Mirrors Go `importMap` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Tracks all stream imports, service imports, and reverse-response maps.
/// Mirrors Go <c>importMap</c> struct in server/accounts.go.
/// </summary>
internal sealed class ImportMap
{
/// <summary>
/// Imported streams (ordered list).
/// Mirrors Go <c>streams []*streamImport</c>.
/// </summary>
public List<StreamImportEntry>? Streams { get; set; }
/// <summary>
/// Imported services keyed by subject pattern; each key may have
/// multiple import entries (e.g. fan-out imports).
/// Mirrors Go <c>services map[string][]*serviceImport</c>.
/// </summary>
public Dictionary<string, List<ServiceImportEntry>>? Services { get; set; }
/// <summary>
/// Reverse-response map used to clean up singleton service imports.
/// Mirrors Go <c>rrMap map[string][]*serviceRespEntry</c>.
/// </summary>
public Dictionary<string, List<ServiceRespEntry>>? ReverseResponseMap { get; set; }
}
// ============================================================================
// StreamImportEntry — an imported stream mapping
// Mirrors Go `streamImport` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// An imported stream from another account, with optional subject remapping.
/// Mirrors Go <c>streamImport</c> struct in server/accounts.go.
/// </summary>
internal sealed class StreamImportEntry
{
/// <summary>Account providing the stream. Mirrors Go <c>acc</c>.</summary>
public Account? Account { get; set; }
/// <summary>Source subject on the exporting account. Mirrors Go <c>from</c>.</summary>
public string From { get; set; } = string.Empty;
/// <summary>Destination subject on the importing account. Mirrors Go <c>to</c>.</summary>
public string To { get; set; } = string.Empty;
/// <summary>
/// Subject transform applied to the source subject.
/// Mirrors Go <c>tr *subjectTransform</c>.
/// Stubbed as <see cref="ISubjectTransformer"/> until the transform
/// engine is wired in.
/// </summary>
public ISubjectTransformer? Transform { get; set; }
/// <summary>
/// Reverse transform for reply subjects.
/// Mirrors Go <c>rtr *subjectTransform</c>.
/// </summary>
public ISubjectTransformer? ReverseTransform { get; set; }
/// <summary>
/// JWT import claim that authorized this import.
/// Mirrors Go <c>claim *jwt.Import</c>.
/// Stubbed as <c>object?</c> until JWT integration is complete (session 11).
/// </summary>
public object? Claim { get; set; }
/// <summary>
/// When true, use the published subject instead of <see cref="To"/>.
/// Mirrors Go <c>usePub</c>.
/// </summary>
public bool UsePublishedSubject { get; set; }
/// <summary>Whether this import is considered invalid. Mirrors Go <c>invalid</c>.</summary>
public bool Invalid { get; set; }
/// <summary>
/// When true, tracing is allowed past the account boundary.
/// Mirrors Go <c>atrc</c> (allow_trace).
/// </summary>
public bool AllowTrace { get; set; }
}
// ============================================================================
// ServiceImportEntry — an imported service mapping
// Mirrors Go `serviceImport` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// An imported service from another account, with response routing and
/// latency tracking state.
/// Mirrors Go <c>serviceImport</c> struct in server/accounts.go.
/// </summary>
internal sealed class ServiceImportEntry
{
/// <summary>Account providing the service. Mirrors Go <c>acc</c>.</summary>
public Account? Account { get; set; }
/// <summary>
/// JWT import claim that authorized this import.
/// Mirrors Go <c>claim *jwt.Import</c>.
/// Stubbed as <c>object?</c> until JWT integration is complete (session 11).
/// </summary>
public object? Claim { get; set; }
/// <summary>Parent service export entry. Mirrors Go <c>se *serviceExport</c>.</summary>
public ServiceExportEntry? ServiceExport { get; set; }
/// <summary>
/// Subscription ID byte slice for cleanup.
/// Mirrors Go <c>sid []byte</c>.
/// </summary>
public byte[]? SubscriptionId { get; set; }
/// <summary>Source subject on the importing account. Mirrors Go <c>from</c>.</summary>
public string From { get; set; } = string.Empty;
/// <summary>Destination subject on the exporting account. Mirrors Go <c>to</c>.</summary>
public string To { get; set; } = string.Empty;
/// <summary>
/// Subject transform applied when routing requests.
/// Mirrors Go <c>tr *subjectTransform</c>.
/// Stubbed as <see cref="ISubjectTransformer"/> until transform engine is wired in.
/// </summary>
public ISubjectTransformer? Transform { get; set; }
/// <summary>
/// Timestamp (Unix nanoseconds) when the import request was created.
/// Used for latency tracking. Mirrors Go <c>ts int64</c>.
/// </summary>
public long Timestamp { get; set; }
/// <summary>Response type for this service import. Mirrors Go <c>rt ServiceRespType</c>.</summary>
public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton;
/// <summary>Latency tracking configuration. Mirrors Go <c>latency *serviceLatency</c>.</summary>
public InternalServiceLatency? Latency { get; set; }
/// <summary>
/// First-leg latency measurement (requestor side).
/// Mirrors Go <c>m1 *ServiceLatency</c>.
/// </summary>
public ServiceLatency? M1 { get; set; }
/// <summary>
/// Client connection that sent the original request.
/// Mirrors Go <c>rc *client</c>.
/// </summary>
public ClientConnection? RequestingClient { get; set; }
/// <summary>
/// When true, use the published subject instead of <see cref="To"/>.
/// Mirrors Go <c>usePub</c>.
/// </summary>
public bool UsePublishedSubject { get; set; }
/// <summary>
/// When true, this import entry represents a pending response rather
/// than an originating request.
/// Mirrors Go <c>response</c>.
/// </summary>
public bool IsResponse { get; set; }
/// <summary>Whether this import is considered invalid. Mirrors Go <c>invalid</c>.</summary>
public bool Invalid { get; set; }
/// <summary>
/// When true, the requestor's <see cref="ClientInfo"/> is shared with
/// the responder. Mirrors Go <c>share</c>.
/// </summary>
public bool Share { get; set; }
/// <summary>Whether latency tracking is active. Mirrors Go <c>tracking</c>.</summary>
public bool Tracking { get; set; }
/// <summary>Whether a response was delivered to the requestor. Mirrors Go <c>didDeliver</c>.</summary>
public bool DidDeliver { get; set; }
/// <summary>
/// When true, tracing is allowed past the account boundary (inherited
/// from the service export). Mirrors Go <c>atrc</c>.
/// </summary>
public bool AllowTrace { get; set; }
/// <summary>
/// Headers from the original request, used when latency is triggered by
/// a header. Mirrors Go <c>trackingHdr http.Header</c>.
/// </summary>
public Dictionary<string, string[]>? TrackingHeader { get; set; }
}
// ============================================================================
// ServiceRespEntry — reverse-response map entry
// Mirrors Go `serviceRespEntry` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Records a service import mapping for reverse-response-map cleanup.
/// Mirrors Go <c>serviceRespEntry</c> struct in server/accounts.go.
/// </summary>
internal sealed class ServiceRespEntry
{
/// <summary>Account that owns the service import. Mirrors Go <c>acc</c>.</summary>
public Account? Account { get; set; }
/// <summary>
/// The mapped subscription subject used for the response.
/// Mirrors Go <c>msub</c>.
/// </summary>
public string MappedSubject { get; set; } = string.Empty;
}
// ============================================================================
// MapDest — public API for weighted subject mappings
// Mirrors Go `MapDest` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Describes a weighted mapping destination for published subjects.
/// Mirrors Go <c>MapDest</c> struct in server/accounts.go.
/// </summary>
public sealed class MapDest
{
[JsonPropertyName("subject")]
public string Subject { get; set; } = string.Empty;
[JsonPropertyName("weight")]
public byte Weight { get; set; }
[JsonPropertyName("cluster")]
public string Cluster { get; set; } = string.Empty;
/// <summary>
/// Creates a new <see cref="MapDest"/> with the given subject and weight.
/// Mirrors Go <c>NewMapDest</c>.
/// </summary>
public static MapDest New(string subject, byte weight) =>
new() { Subject = subject, Weight = weight };
}
// ============================================================================
// Destination — internal weighted mapped destination
// Mirrors Go `destination` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Internal representation of a weighted mapped destination, holding a
/// transform and a weight.
/// Mirrors Go <c>destination</c> struct in server/accounts.go.
/// </summary>
internal sealed class Destination
{
/// <summary>
/// Transform that converts the source subject to the destination subject.
/// Mirrors Go <c>tr *subjectTransform</c>.
/// </summary>
public ISubjectTransformer? Transform { get; set; }
/// <summary>
/// Relative weight (0100). Mirrors Go <c>weight uint8</c>.
/// </summary>
public byte Weight { get; set; }
}
// ============================================================================
// SubjectMapping — internal subject mapping entry
// Mirrors Go `mapping` struct in server/accounts.go.
// Renamed from `mapping` to avoid collision with the C# keyword context.
// ============================================================================
/// <summary>
/// An internal entry describing how a source subject is remapped to one or
/// more weighted destinations, optionally scoped to specific clusters.
/// Mirrors Go <c>mapping</c> struct in server/accounts.go.
/// </summary>
internal sealed class SubjectMapping
{
/// <summary>Source subject pattern. Mirrors Go <c>src</c>.</summary>
public string Source { get; set; } = string.Empty;
/// <summary>
/// Whether the source contains wildcards.
/// Mirrors Go <c>wc</c>.
/// </summary>
public bool HasWildcard { get; set; }
/// <summary>
/// Weighted destinations with no cluster scope.
/// Mirrors Go <c>dests []*destination</c>.
/// </summary>
public List<Destination> Destinations { get; set; } = [];
/// <summary>
/// Per-cluster weighted destinations.
/// Key is the cluster name. Mirrors Go <c>cdests map[string][]*destination</c>.
/// </summary>
public Dictionary<string, List<Destination>>? ClusterDestinations { get; set; }
}
// ============================================================================
// TypedEvent — base for server advisory events
// Mirrors Go `TypedEvent` struct in server/events.go.
// Included here because ServiceLatency embeds it.
// ============================================================================
/// <summary>
/// Base fields for a NATS typed event or advisory.
/// Mirrors Go <c>TypedEvent</c> struct in server/events.go.
/// </summary>
public class TypedEvent
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("timestamp")]
public DateTime Time { get; set; }
}
// ============================================================================
// ServiceLatency — public latency measurement event
// Mirrors Go `ServiceLatency` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// The JSON message published to a latency-tracking subject when a service
/// request completes. Includes requestor and responder timing breakdowns.
/// Mirrors Go <c>ServiceLatency</c> struct in server/accounts.go.
/// </summary>
public sealed class ServiceLatency : TypedEvent
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("description")]
public string Error { get; set; } = string.Empty;
[JsonPropertyName("requestor")]
public ClientInfo? Requestor { get; set; }
[JsonPropertyName("responder")]
public ClientInfo? Responder { get; set; }
/// <summary>
/// Headers from the original request that triggered latency measurement.
/// Mirrors Go <c>RequestHeader http.Header</c>.
/// </summary>
[JsonPropertyName("header")]
public Dictionary<string, string[]>? RequestHeader { get; set; }
[JsonPropertyName("start")]
public DateTime RequestStart { get; set; }
/// <summary>Mirrors Go <c>ServiceLatency time.Duration</c> (nanoseconds).</summary>
[JsonPropertyName("service")]
public TimeSpan ServiceLatencyDuration { get; set; }
/// <summary>Mirrors Go <c>SystemLatency time.Duration</c> (nanoseconds).</summary>
[JsonPropertyName("system")]
public TimeSpan SystemLatency { get; set; }
/// <summary>Mirrors Go <c>TotalLatency time.Duration</c> (nanoseconds).</summary>
[JsonPropertyName("total")]
public TimeSpan TotalLatency { get; set; }
/// <summary>
/// Returns the sum of requestor RTT, responder RTT, and system latency.
/// Mirrors Go <c>ServiceLatency.NATSTotalTime()</c>.
/// </summary>
public TimeSpan NATSTotalTime()
{
var requestorRtt = Requestor?.Rtt ?? TimeSpan.Zero;
var responderRtt = Responder?.Rtt ?? TimeSpan.Zero;
return requestorRtt + responderRtt + SystemLatency;
}
}
// ============================================================================
// RemoteLatency — cross-server latency transport message
// Mirrors Go `remoteLatency` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Used to transport a responder-side latency measurement to the
/// requestor's server so the two halves can be merged.
/// Mirrors Go <c>remoteLatency</c> struct in server/accounts.go.
/// </summary>
internal sealed class RemoteLatency
{
[JsonPropertyName("account")]
public string Account { get; set; } = string.Empty;
[JsonPropertyName("req_id")]
public string RequestId { get; set; } = string.Empty;
[JsonPropertyName("m2")]
public ServiceLatency M2 { get; set; } = new();
/// <summary>
/// Private: response latency threshold used when deciding whether to
/// send the remote measurement.
/// Mirrors Go <c>respThresh time.Duration</c>.
/// </summary>
public TimeSpan ResponseThreshold { get; set; }
}
// ============================================================================
// RsiReason — reason for removing a response service import
// Mirrors Go `rsiReason` and its iota constants in server/accounts.go.
// ============================================================================
/// <summary>
/// The reason a response service import entry is being removed.
/// Mirrors Go <c>rsiReason</c> and its iota constants in server/accounts.go.
/// </summary>
internal enum RsiReason
{
/// <summary>Normal completion. Mirrors Go <c>rsiOk</c>.</summary>
Ok = 0,
/// <summary>Response was never delivered. Mirrors Go <c>rsiNoDelivery</c>.</summary>
NoDelivery = 1,
/// <summary>Response timed out. Mirrors Go <c>rsiTimeout</c>.</summary>
Timeout = 2,
}
// ============================================================================
// Account-level constants
// Mirrors the const blocks in server/accounts.go.
// ============================================================================
/// <summary>
/// Constants related to account route-pool indexing and search depth.
/// </summary>
internal static class AccountConstants
{
/// <summary>
/// Sentinel value indicating the account has a dedicated route connection.
/// Mirrors Go <c>accDedicatedRoute = -1</c>.
/// </summary>
public const int DedicatedRoute = -1;
/// <summary>
/// Sentinel value indicating the account is in the process of transitioning
/// to a dedicated route.
/// Mirrors Go <c>accTransitioningToDedicatedRoute = -2</c>.
/// </summary>
public const int TransitioningToDedicatedRoute = -2;
/// <summary>
/// Maximum depth for account cycle detection when following import chains.
/// Mirrors Go <c>MaxAccountCycleSearchDepth = 1024</c>.
/// </summary>
public const int MaxCycleSearchDepth = 1024;
}
/// <summary>
/// Well-known header names and event type identifiers used by the account
/// service-latency and client-info subsystems.
/// </summary>
public static class AccountEventConstants
{
/// <summary>
/// Header name used to pass client metadata into a service request.
/// Mirrors Go <c>ClientInfoHdr = "Nats-Request-Info"</c>.
/// </summary>
public const string ClientInfoHeader = "Nats-Request-Info";
/// <summary>
/// The default threshold (in nanoseconds, as a <see cref="TimeSpan"/>) below
/// which a subscription-limit report is suppressed.
/// Mirrors Go <c>defaultMaxSubLimitReportThreshold = int64(2 * time.Second)</c>.
/// </summary>
public static readonly TimeSpan DefaultMaxSubLimitReportThreshold = TimeSpan.FromSeconds(2);
/// <summary>
/// NATS event type identifier for <see cref="ServiceLatency"/> messages.
/// Mirrors Go <c>ServiceLatencyType = "io.nats.server.metric.v1.service_latency"</c>.
/// </summary>
public const string ServiceLatencyType = "io.nats.server.metric.v1.service_latency";
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
// 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 server/auth_callout.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Auth;
/// <summary>
/// External auth callout support.
/// Mirrors Go <c>auth_callout.go</c>.
/// </summary>
internal static class AuthCallout
{
/// <summary>
/// Publishes an auth request to the configured callout account and awaits
/// a signed JWT response that authorises or rejects the connecting client.
/// Mirrors Go <c>processClientOrLeafCallout</c> in auth_callout.go.
/// </summary>
public static bool ProcessClientOrLeafCallout(NatsServer server, ClientConnection c, ServerOptions opts)
{
// Full implementation requires internal NATS pub/sub with async request/reply.
// This is intentionally left as a stub until the internal NATS connection layer is available.
throw new NotImplementedException(
"Auth callout requires internal NATS pub/sub — implement when connection layer is available.");
}
/// <summary>
/// Populates an authorization request payload with client connection info.
/// Mirrors Go <c>client.fillClientInfo</c> in auth_callout.go.
/// </summary>
public static void FillClientInfo(AuthorizationRequest req, ClientConnection c)
{
req.ClientInfoObj = new AuthorizationClientInfo
{
Host = c.Host,
Id = c.Cid,
Kind = c.Kind.ToString().ToLowerInvariant(),
Type = "client",
};
}
/// <summary>
/// Populates an authorization request payload with connect options.
/// Mirrors Go <c>client.fillConnectOpts</c> in auth_callout.go.
/// </summary>
public static void FillConnectOpts(AuthorizationRequest req, ClientConnection c)
{
req.ConnectOptions = new AuthorizationConnectOpts
{
Username = c.GetUsername(),
Password = c.GetPassword(),
AuthToken = c.GetAuthToken(),
Nkey = c.GetNkey(),
};
}
}
/// <summary>Authorization request sent to auth callout service.</summary>
public sealed class AuthorizationRequest
{
public string ServerId { get; set; } = string.Empty;
public string UserNkey { get; set; } = string.Empty;
public AuthorizationClientInfo? ClientInfoObj { get; set; }
public AuthorizationConnectOpts? ConnectOptions { get; set; }
}
/// <summary>Client info portion of an authorization request.</summary>
public sealed class AuthorizationClientInfo
{
public string Host { get; set; } = string.Empty;
public ulong Id { get; set; }
public string Kind { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
}
/// <summary>Connect options portion of an authorization request.</summary>
public sealed class AuthorizationConnectOpts
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string AuthToken { get; set; } = string.Empty;
public string Nkey { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,374 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/auth.go (standalone functions) in the NATS server Go source.
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.Auth;
/// <summary>
/// Authentication helper methods ported from Go auth.go.
/// Server-dependent methods (configureAuthorization, checkAuthentication, etc.)
/// will be added in later sessions when the full Server type is available.
/// </summary>
public static partial class AuthHandler
{
/// <summary>
/// Regex matching valid bcrypt password prefixes ($2a$, $2b$, $2x$, $2y$).
/// Mirrors Go <c>validBcryptPrefix</c>.
/// </summary>
private static readonly Regex ValidBcryptPrefix = ValidBcryptPrefixRegex();
[GeneratedRegex(@"^\$2[abxy]\$\d{2}\$.*")]
private static partial Regex ValidBcryptPrefixRegex();
/// <summary>
/// Checks if a password string is a bcrypt hash.
/// Mirrors Go <c>isBcrypt</c>.
/// </summary>
public static bool IsBcrypt(string password)
{
if (password.StartsWith('$'))
{
return ValidBcryptPrefix.IsMatch(password);
}
return false;
}
/// <summary>
/// Compares a server password (possibly bcrypt-hashed) against a client-provided password.
/// Uses constant-time comparison for plaintext passwords.
/// Mirrors Go <c>comparePasswords</c>.
/// </summary>
public static bool ComparePasswords(string serverPassword, string clientPassword)
{
if (IsBcrypt(serverPassword))
{
return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword);
}
// Constant-time comparison for plaintext passwords.
var spass = Encoding.UTF8.GetBytes(serverPassword);
var cpass = Encoding.UTF8.GetBytes(clientPassword);
return CryptographicOperations.FixedTimeEquals(spass, cpass);
}
/// <summary>
/// Validates the ResponsePermission defaults within a Permissions struct.
/// If Response is set but MaxMsgs/Expires are zero, applies defaults.
/// Also ensures Publish is set with an empty Allow if not already defined.
/// Mirrors Go <c>validateResponsePermissions</c>.
/// </summary>
public static void ValidateResponsePermissions(Permissions? p)
{
if (p?.Response == null)
{
return;
}
p.Publish ??= new SubjectPermission();
p.Publish.Allow ??= [];
if (p.Response.MaxMsgs == 0)
{
p.Response.MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs;
}
if (p.Response.Expires == TimeSpan.Zero)
{
p.Response.Expires = ServerConstants.DefaultAllowResponseExpiration;
}
}
/// <summary>
/// Known connection type strings (uppercased).
/// Mirrors Go jwt.ConnectionType* constants.
/// </summary>
public static class ConnectionTypes
{
public const string Standard = "STANDARD";
public const string Websocket = "WEBSOCKET";
public const string Leafnode = "LEAFNODE";
public const string LeafnodeWs = "LEAFNODE_WS";
public const string Mqtt = "MQTT";
public const string MqttWs = "MQTT_WS";
public const string InProcess = "IN_PROCESS";
private static readonly HashSet<string> Known =
[
Standard,
Websocket,
Leafnode,
LeafnodeWs,
Mqtt,
MqttWs,
InProcess,
];
public static bool IsKnown(string ct) => Known.Contains(ct);
}
/// <summary>
/// Validates allowed connection type map entries. Normalises to uppercase
/// and rejects unknown types.
/// Mirrors Go <c>validateAllowedConnectionTypes</c>.
/// </summary>
public static Exception? ValidateAllowedConnectionTypes(HashSet<string>? m)
{
if (m == null) return null;
// We must iterate a copy since we may modify the set.
var entries = m.ToList();
foreach (var ct in entries)
{
var ctuc = ct.ToUpperInvariant();
if (!ConnectionTypes.IsKnown(ctuc))
{
return new ArgumentException($"unknown connection type \"{ct}\"");
}
if (ctuc != ct)
{
m.Remove(ct);
m.Add(ctuc);
}
}
return null;
}
/// <summary>
/// Validates the no_auth_user setting against configured users/nkeys.
/// Mirrors Go <c>validateNoAuthUser</c>.
/// </summary>
public static Exception? ValidateNoAuthUser(ServerOptions o, string noAuthUser)
{
if (string.IsNullOrEmpty(noAuthUser))
{
return null;
}
if (o.TrustedOperators.Count > 0)
{
return new InvalidOperationException("no_auth_user not compatible with Trusted Operator");
}
if (o.Nkeys == null && o.Users == null)
{
return new InvalidOperationException(
$"no_auth_user: \"{noAuthUser}\" present, but users/nkeys are not defined");
}
if (o.Users != null)
{
foreach (var u in o.Users)
{
if (u.Username == noAuthUser) return null;
}
}
if (o.Nkeys != null)
{
foreach (var u in o.Nkeys)
{
if (u.Nkey == noAuthUser) return null;
}
}
return new InvalidOperationException(
$"no_auth_user: \"{noAuthUser}\" not present as user or nkey in authorization block or account configuration");
}
/// <summary>
/// Validates the auth section of options: pinned certs, connection types, and no_auth_user.
/// Mirrors Go <c>validateAuth</c>.
/// </summary>
public static Exception? ValidateAuth(ServerOptions o)
{
// validatePinnedCerts will be added when the full server module is ported.
if (o.Users != null)
{
foreach (var u in o.Users)
{
var err = ValidateAllowedConnectionTypes(u.AllowedConnectionTypes);
if (err != null) return err;
}
}
if (o.Nkeys != null)
{
foreach (var u in o.Nkeys)
{
var err = ValidateAllowedConnectionTypes(u.AllowedConnectionTypes);
if (err != null) return err;
}
}
return ValidateNoAuthUser(o, o.NoAuthUser);
}
/// <summary>
/// Splits a DNS alt name into lowercase labels.
/// Mirrors Go <c>dnsAltNameLabels</c>.
/// </summary>
public static string[] DnsAltNameLabels(string dnsAltName)
{
return dnsAltName.ToLowerInvariant().Split('.');
}
/// <summary>
/// Checks if DNS alt name labels match any of the provided URLs (RFC 6125).
/// The wildcard '*' only matches the leftmost label.
/// Mirrors Go <c>dnsAltNameMatches</c>.
/// </summary>
public static bool DnsAltNameMatches(string[] dnsAltNameLabels, IReadOnlyList<Uri?> urls)
{
foreach (var url in urls)
{
if (url == null)
{
continue;
}
var hostLabels = url.Host.ToLowerInvariant().Split('.');
// Following RFC 6125: wildcard never matches multiple labels, only leftmost.
if (hostLabels.Length != dnsAltNameLabels.Length)
{
continue;
}
var i = 0;
// Only match wildcard on leftmost label.
if (dnsAltNameLabels[0] == "*")
{
i++;
}
var matched = true;
for (; i < dnsAltNameLabels.Length; i++)
{
if (dnsAltNameLabels[i] != hostLabels[i])
{
matched = false;
break;
}
}
if (matched) return true;
}
return false;
}
/// <summary>
/// Wipes a byte slice by filling with 'x'. Used for clearing sensitive data.
/// Mirrors Go <c>wipeSlice</c>.
/// </summary>
public static void WipeSlice(Span<byte> buf)
{
buf.Fill((byte)'x');
}
/// <summary>
/// Returns the closed-client state for an auth error.
/// Mirrors Go <c>getAuthErrClosedState</c> in server/auth.go.
/// </summary>
public static ClosedState GetAuthErrClosedState(Exception? err)
{
return err switch
{
AuthProxyNotTrustedException => ClosedState.ProxyNotTrusted,
AuthProxyRequiredException => ClosedState.ProxyRequired,
_ => ClosedState.AuthenticationViolation,
};
}
/// <summary>
/// Validates that proxy protocol configuration is consistent.
/// If <see cref="ServerOptions.ProxyRequired"/> is set, <see cref="ServerOptions.ProxyProtocol"/> must also be enabled.
/// Note: Full NKey-format validation of trusted proxy keys is deferred until proxy auth is fully implemented.
/// Partially mirrors Go <c>validateProxies</c> in server/auth.go.
/// </summary>
public static Exception? ValidateProxies(ServerOptions opts)
{
if (opts.ProxyRequired && !opts.ProxyProtocol)
return new InvalidOperationException("proxy_required requires proxy_protocol to be enabled");
return null;
}
/// <summary>
/// Extracts the DC= attribute values from a certificate's distinguished name.
/// Mirrors Go <c>getTLSAuthDCs</c> in server/auth.go.
/// </summary>
public static string GetTlsAuthDcs(System.Security.Cryptography.X509Certificates.X509Certificate2 cert)
{
var subject = cert.Subject;
var dcs = new System.Text.StringBuilder();
foreach (var part in subject.Split(','))
{
var trimmed = part.Trim();
if (trimmed.StartsWith("DC=", StringComparison.OrdinalIgnoreCase))
{
if (dcs.Length > 0) dcs.Append('.');
dcs.Append(trimmed[3..]);
}
}
return dcs.ToString();
}
/// <summary>
/// Checks whether a client's TLS certificate subject matches using the provided matcher function.
/// Mirrors Go <c>checkClientTLSCertSubject</c> in server/auth.go.
/// </summary>
public static bool CheckClientTlsCertSubject(
System.Security.Cryptography.X509Certificates.X509Certificate2? cert,
Func<string, bool> matcher)
{
if (cert == null) return false;
return matcher(cert.Subject);
}
/// <summary>
/// Expands template variables ({{account}}, {{tag.*}}) in JWT user permission limits.
/// Mirrors Go <c>processUserPermissionsTemplate</c> in server/auth.go.
/// </summary>
public static (Permissions Result, Exception? Error) ProcessUserPermissionsTemplate(
Permissions lim,
string accountName,
Dictionary<string, string>? tags)
{
ExpandSubjectList(lim.Publish?.Allow, accountName, tags);
ExpandSubjectList(lim.Publish?.Deny, accountName, tags);
ExpandSubjectList(lim.Subscribe?.Allow, accountName, tags);
ExpandSubjectList(lim.Subscribe?.Deny, accountName, tags);
return (lim, null);
}
private static readonly Regex TemplateVar =
new(@"\{\{(\w+(?:\.\w+)*)\}\}", RegexOptions.Compiled);
private static void ExpandSubjectList(List<string>? subjects, string accountName, Dictionary<string, string>? tags)
{
if (subjects == null) return;
for (var i = 0; i < subjects.Count; i++)
subjects[i] = ExpandTemplate(subjects[i], accountName, tags);
}
private static string ExpandTemplate(string subject, string accountName, Dictionary<string, string>? tags)
{
return TemplateVar.Replace(subject, m =>
{
var key = m.Groups[1].Value;
if (key.Equals("account", StringComparison.OrdinalIgnoreCase)) return accountName;
if (key.StartsWith("tag.", StringComparison.OrdinalIgnoreCase) && tags != null)
{
var tagKey = key[4..];
return tags.TryGetValue(tagKey, out var v) ? v : m.Value;
}
return m.Value;
});
}
}

View File

@@ -0,0 +1,190 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/auth.go (type definitions) in the NATS server Go source.
using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.Auth;
/// <summary>
/// Represents a user authenticated via NKey.
/// Mirrors Go <c>NkeyUser</c> struct in auth.go.
/// </summary>
public class NkeyUser
{
public string Nkey { get; set; } = string.Empty;
public long Issued { get; set; }
public Permissions? Permissions { get; set; }
public Account? Account { get; set; }
public string SigningKey { get; set; } = string.Empty;
public HashSet<string>? AllowedConnectionTypes { get; set; }
public bool ProxyRequired { get; set; }
/// <summary>
/// Deep-clones this NkeyUser. Account is shared by reference.
/// Mirrors Go <c>NkeyUser.clone()</c>.
/// </summary>
public NkeyUser? Clone()
{
var clone = (NkeyUser)MemberwiseClone();
// Account is not cloned because it is always by reference to an existing struct.
clone.Permissions = Permissions?.Clone();
if (AllowedConnectionTypes != null)
{
clone.AllowedConnectionTypes = new HashSet<string>(AllowedConnectionTypes);
}
return clone;
}
}
/// <summary>
/// Represents a user with username/password credentials.
/// Mirrors Go <c>User</c> struct in auth.go.
/// </summary>
public class User
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public Permissions? Permissions { get; set; }
public Account? Account { get; set; }
public DateTime ConnectionDeadline { get; set; }
public HashSet<string>? AllowedConnectionTypes { get; set; }
public bool ProxyRequired { get; set; }
/// <summary>
/// Deep-clones this User. Account is shared by reference.
/// Mirrors Go <c>User.clone()</c>.
/// </summary>
public User? Clone()
{
var clone = (User)MemberwiseClone();
// Account is not cloned because it is always by reference to an existing struct.
clone.Permissions = Permissions?.Clone();
if (AllowedConnectionTypes != null)
{
clone.AllowedConnectionTypes = new HashSet<string>(AllowedConnectionTypes);
}
return clone;
}
}
/// <summary>
/// Subject-level allow/deny permission.
/// Mirrors Go <c>SubjectPermission</c> in auth.go.
/// </summary>
public class SubjectPermission
{
public List<string>? Allow { get; set; }
public List<string>? Deny { get; set; }
/// <summary>
/// Deep-clones this SubjectPermission.
/// Mirrors Go <c>SubjectPermission.clone()</c>.
/// </summary>
public SubjectPermission Clone()
{
var clone = new SubjectPermission();
if (Allow != null)
{
clone.Allow = new List<string>(Allow);
}
if (Deny != null)
{
clone.Deny = new List<string>(Deny);
}
return clone;
}
}
/// <summary>
/// Response permission for request-reply patterns.
/// Mirrors Go <c>ResponsePermission</c> in auth.go.
/// </summary>
public class ResponsePermission
{
public int MaxMsgs { get; set; }
public TimeSpan Expires { get; set; }
}
/// <summary>
/// Publish/subscribe permissions container.
/// Mirrors Go <c>Permissions</c> in auth.go.
/// </summary>
public class Permissions
{
public SubjectPermission? Publish { get; set; }
public SubjectPermission? Subscribe { get; set; }
public ResponsePermission? Response { get; set; }
/// <summary>
/// Deep-clones this Permissions struct.
/// Mirrors Go <c>Permissions.clone()</c>.
/// </summary>
public Permissions Clone()
{
var clone = new Permissions();
if (Publish != null)
{
clone.Publish = Publish.Clone();
}
if (Subscribe != null)
{
clone.Subscribe = Subscribe.Clone();
}
if (Response != null)
{
clone.Response = new ResponsePermission
{
MaxMsgs = Response.MaxMsgs,
Expires = Response.Expires,
};
}
return clone;
}
}
/// <summary>
/// Route-level import/export permissions.
/// Mirrors Go <c>RoutePermissions</c> in auth.go.
/// </summary>
public class RoutePermissions
{
public SubjectPermission? Import { get; set; }
public SubjectPermission? Export { get; set; }
}
// Account stub removed — full implementation is in Accounts/Account.cs
// in the ZB.MOM.NatsNet.Server namespace.
/// <summary>
/// Sentinel exception representing a proxy-auth "not trusted" error.
/// Mirrors Go <c>ErrAuthProxyNotTrusted</c> in server/auth.go.
/// </summary>
public sealed class AuthProxyNotTrustedException : InvalidOperationException
{
public AuthProxyNotTrustedException() : base("proxy not trusted") { }
}
/// <summary>
/// Sentinel exception representing a proxy-auth "required" error.
/// Mirrors Go <c>ErrAuthProxyRequired</c> in server/auth.go.
/// </summary>
public sealed class AuthProxyRequiredException : InvalidOperationException
{
public AuthProxyRequiredException() : base("proxy required") { }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,110 @@
// Copyright 2016-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/ciphersuites.go in the NATS server Go source.
using System.Net.Security;
using System.Security.Authentication;
namespace ZB.MOM.NatsNet.Server.Auth;
/// <summary>
/// TLS cipher suite and curve preference definitions.
/// Mirrors Go <c>ciphersuites.go</c> — cipherMap, defaultCipherSuites, curvePreferenceMap,
/// defaultCurvePreferences.
/// </summary>
public static class CipherSuites
{
/// <summary>
/// Map of cipher suite names to their <see cref="TlsCipherSuite"/> values.
/// Populated at static init time — mirrors Go <c>init()</c> + <c>cipherMap</c>.
/// </summary>
public static IReadOnlyDictionary<string, TlsCipherSuite> CipherMap { get; }
/// <summary>
/// Reverse map of cipher suite ID to name.
/// Mirrors Go <c>cipherMapByID</c>.
/// </summary>
public static IReadOnlyDictionary<TlsCipherSuite, string> CipherMapById { get; }
static CipherSuites()
{
// .NET does not have a direct equivalent of Go's tls.CipherSuites() /
// tls.InsecureCipherSuites() enumeration. We enumerate the well-known
// TLS 1.2 and 1.3 cipher suites defined in the TlsCipherSuite enum.
var byName = new Dictionary<string, TlsCipherSuite>(StringComparer.OrdinalIgnoreCase);
var byId = new Dictionary<TlsCipherSuite, string>();
foreach (TlsCipherSuite cs in Enum.GetValues(typeof(TlsCipherSuite)))
{
var name = cs.ToString();
byName.TryAdd(name, cs);
byId.TryAdd(cs, name);
}
CipherMap = byName;
CipherMapById = byId;
}
/// <summary>
/// Returns the default set of TLS 1.2 cipher suites.
/// .NET manages cipher suite selection at the OS/SChannel/OpenSSL level;
/// this list provides the preferred suites for configuration alignment with Go.
/// Mirrors Go <c>defaultCipherSuites</c>.
/// </summary>
public static TlsCipherSuite[] DefaultCipherSuites()
{
// Return commonly-used TLS 1.2 cipher suites in preference order.
// TLS 1.3 suites are always enabled in .NET and cannot be individually toggled.
return
[
TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
TlsCipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
TlsCipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
TlsCipherSuite.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
];
}
/// <summary>
/// Supported named curve / key exchange preferences.
/// Mirrors Go <c>curvePreferenceMap</c>.
/// </summary>
public static IReadOnlyDictionary<string, SslApplicationProtocol> CurvePreferenceMap { get; } =
new Dictionary<string, SslApplicationProtocol>(StringComparer.OrdinalIgnoreCase)
{
// .NET does not expose individual curve selection in the same way as Go.
// These entries exist for configuration-file compatibility and mapping.
// Actual curve negotiation is handled by the OS TLS stack.
["X25519"] = new SslApplicationProtocol("X25519"),
["CurveP256"] = new SslApplicationProtocol("CurveP256"),
["CurveP384"] = new SslApplicationProtocol("CurveP384"),
["CurveP521"] = new SslApplicationProtocol("CurveP521"),
};
/// <summary>
/// Returns the default curve preferences, ordered highest security first.
/// Mirrors Go <c>defaultCurvePreferences</c>.
/// </summary>
public static string[] DefaultCurvePreferences()
{
return
[
"X25519",
"CurveP256",
"CurveP384",
"CurveP521",
];
}
}

View File

@@ -0,0 +1,236 @@
// Copyright 2018-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/jwt.go in the NATS server Go source.
using System.Net;
using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.Auth;
/// <summary>
/// JWT processing utilities for NATS operator/account/user JWTs.
/// Mirrors Go <c>jwt.go</c> functions.
/// Full JWT parsing will be added when a .NET JWT library equivalent is available.
/// </summary>
public static class JwtProcessor
{
/// <summary>
/// All JWTs once encoded start with this prefix.
/// Mirrors Go <c>jwtPrefix</c>.
/// </summary>
public const string JwtPrefix = "eyJ";
/// <summary>
/// Validates that the given IP host address is allowed by the user claims source CIDRs.
/// Returns true if the host is within any of the allowed CIDRs, or if no CIDRs are specified.
/// Mirrors Go <c>validateSrc</c>.
/// </summary>
public static bool ValidateSrc(IReadOnlyList<string>? srcCidrs, string host)
{
if (srcCidrs == null)
{
return false;
}
if (srcCidrs.Count == 0)
{
return true;
}
if (string.IsNullOrEmpty(host))
{
return false;
}
if (!IPAddress.TryParse(host, out var ip))
{
return false;
}
foreach (var cidr in srcCidrs)
{
if (TryParseCidr(cidr, out var network, out var prefixLength))
{
if (IsInSubnet(ip, network, prefixLength))
{
return true;
}
}
else
{
return false; // invalid CIDR means invalid JWT
}
}
return false;
}
/// <summary>
/// Validates that the current time falls within any of the allowed time ranges.
/// Returns (allowed, remainingDuration).
/// Mirrors Go <c>validateTimes</c>.
/// </summary>
public static (bool Allowed, TimeSpan Remaining) ValidateTimes(
IReadOnlyList<TimeRange>? timeRanges,
string? locale = null)
{
if (timeRanges == null)
{
return (false, TimeSpan.Zero);
}
if (timeRanges.Count == 0)
{
return (true, TimeSpan.Zero);
}
var now = DateTimeOffset.Now;
TimeZoneInfo? tz = null;
if (!string.IsNullOrEmpty(locale))
{
try
{
tz = TimeZoneInfo.FindSystemTimeZoneById(locale);
now = TimeZoneInfo.ConvertTime(now, tz);
}
catch
{
return (false, TimeSpan.Zero);
}
}
foreach (var timeRange in timeRanges)
{
if (!TimeSpan.TryParse(timeRange.Start, out var startTime) ||
!TimeSpan.TryParse(timeRange.End, out var endTime))
{
return (false, TimeSpan.Zero);
}
var today = now.Date;
var start = today + startTime;
var end = today + endTime;
// If start > end, end is on the next day (overnight range).
if (startTime > endTime)
{
end = end.AddDays(1);
}
if (start <= now && now < end)
{
return (true, end - now);
}
}
return (false, TimeSpan.Zero);
}
private static bool TryParseCidr(string cidr, out IPAddress network, out int prefixLength)
{
network = IPAddress.None;
prefixLength = 0;
var slashIndex = cidr.IndexOf('/');
if (slashIndex < 0) return false;
var ipPart = cidr.AsSpan(0, slashIndex);
var prefixPart = cidr.AsSpan(slashIndex + 1);
if (!IPAddress.TryParse(ipPart, out var parsedIp)) return false;
if (!int.TryParse(prefixPart, out var prefix)) return false;
network = parsedIp;
prefixLength = prefix;
return true;
}
private static bool IsInSubnet(IPAddress address, IPAddress network, int prefixLength)
{
var addrBytes = address.GetAddressBytes();
var netBytes = network.GetAddressBytes();
if (addrBytes.Length != netBytes.Length) return false;
var fullBytes = prefixLength / 8;
var remainingBits = prefixLength % 8;
for (var i = 0; i < fullBytes; i++)
{
if (addrBytes[i] != netBytes[i]) return false;
}
if (remainingBits > 0 && fullBytes < addrBytes.Length)
{
var mask = (byte)(0xFF << (8 - remainingBits));
if ((addrBytes[fullBytes] & mask) != (netBytes[fullBytes] & mask)) return false;
}
return true;
}
/// <summary>
/// Reads an operator JWT from a file path. Returns (claims, error).
/// Mirrors Go <c>ReadOperatorJWT</c> in server/jwt.go.
/// </summary>
public static (object? Claims, Exception? Error) ReadOperatorJwt(string path)
{
if (string.IsNullOrEmpty(path))
return (null, new ArgumentException("operator JWT path is empty"));
string jwtString;
try
{
jwtString = File.ReadAllText(path, System.Text.Encoding.ASCII).Trim();
}
catch (Exception ex)
{
return (null, new IOException($"error reading operator JWT file: {ex.Message}", ex));
}
return ReadOperatorJwtInternal(jwtString);
}
/// <summary>
/// Decodes an operator JWT string. Returns (claims, error).
/// Mirrors Go <c>readOperatorJWT</c> in server/jwt.go.
/// </summary>
public static (object? Claims, Exception? Error) ReadOperatorJwtInternal(string jwtString)
{
if (string.IsNullOrEmpty(jwtString))
return (null, new ArgumentException("operator JWT string is empty"));
if (!jwtString.StartsWith(JwtPrefix, StringComparison.Ordinal))
return (null, new FormatException($"operator JWT does not start with expected prefix '{JwtPrefix}'"));
// Full NATS JWT parsing would require a dedicated JWT library.
// At this level, we validate the prefix and structure.
return (null, new FormatException("operator JWT parsing not fully implemented — requires NATS JWT library"));
}
/// <summary>
/// Validates the trusted operator JWTs in options.
/// Mirrors Go <c>validateTrustedOperators</c> in server/jwt.go.
/// </summary>
public static Exception? ValidateTrustedOperators(ServerOptions opts)
{
if (opts.TrustedOperators == null || opts.TrustedOperators.Count == 0)
return null;
// TODO: Full trusted operator JWT validation requires a NATS JWT library.
// Each operator JWT should be decoded and its signing key chain verified.
// For now, we accept any non-empty operator list and validate at connect time.
return null;
}
}
/// <summary>
/// Represents a time-of-day range for user access control.
/// Mirrors Go <c>jwt.TimeRange</c>.
/// </summary>
public class TimeRange
{
public string Start { get; set; } = string.Empty;
public string End { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,315 @@
// Copyright 2021-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/ocsp.go, server/ocsp_peer.go, server/ocsp_responsecache.go
// in the NATS server Go source.
using System.Security.Cryptography.X509Certificates;
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.NatsNet.Server.Auth.Ocsp;
/// <summary>
/// Controls how OCSP stapling behaves for a TLS certificate.
/// Mirrors Go <c>OCSPMode uint8</c> in server/ocsp.go.
/// </summary>
public enum OcspMode : byte
{
/// <summary>
/// Staple only if the "status_request" OID is present in the certificate.
/// Mirrors Go <c>OCSPModeAuto</c>.
/// </summary>
Auto = 0,
/// <summary>
/// Must staple — honors the Must-Staple flag and shuts down on revocation.
/// Mirrors Go <c>OCSPModeMust</c>.
/// </summary>
MustStaple = 1,
/// <summary>
/// Always obtain OCSP status, regardless of certificate flags.
/// Mirrors Go <c>OCSPModeAlways</c>.
/// </summary>
Always = 2,
/// <summary>
/// Never check OCSP, even if the certificate has the Must-Staple flag.
/// Mirrors Go <c>OCSPModeNever</c>.
/// </summary>
Never = 3,
}
/// <summary>
/// Holds a cached OCSP staple response and its expiry information.
/// </summary>
internal sealed class OcspStaple
{
/// <summary>The raw DER-encoded OCSP response bytes.</summary>
public byte[]? Response { get; set; }
/// <summary>When the OCSP response next needs to be refreshed.</summary>
public DateTime NextUpdate { get; set; }
}
/// <summary>
/// Orchestrates OCSP stapling for a single TLS certificate.
/// Monitors certificate validity and refreshes the staple on a background timer.
/// Mirrors Go <c>OCSPMonitor</c> struct in server/ocsp.go.
/// Replaces the stub in NatsServerTypes.cs.
/// </summary>
internal sealed class OcspMonitor
{
private readonly Lock _mu = new();
private Timer? _timer;
private readonly OcspStaple _staple = new();
/// <summary>Path to the TLS certificate file being monitored.</summary>
public string? CertFile { get; set; }
/// <summary>Path to the CA certificate file used to verify OCSP responses.</summary>
public string? CaFile { get; set; }
/// <summary>Path to a persisted OCSP staple file (optional).</summary>
public string? OcspStapleFile { get; set; }
/// <summary>The OCSP stapling mode for this monitor.</summary>
public OcspMode Mode { get; set; }
/// <summary>How often to check for a fresh OCSP response.</summary>
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromHours(24);
/// <summary>The owning server instance.</summary>
public NatsServer? Server { get; set; }
/// <summary>The synchronisation lock for this monitor's mutable state.</summary>
public Lock Mu => _mu;
/// <summary>Starts the background OCSP refresh timer.</summary>
public void Start()
{
lock (_mu)
{
if (_timer != null)
return;
_timer = new Timer(_ =>
{
lock (_mu)
{
if (!string.IsNullOrEmpty(OcspStapleFile) && File.Exists(OcspStapleFile))
_staple.Response = File.ReadAllBytes(OcspStapleFile);
_staple.NextUpdate = DateTime.UtcNow + CheckInterval;
}
}, null, TimeSpan.Zero, CheckInterval);
}
}
/// <summary>Stops the background OCSP refresh timer.</summary>
public void Stop()
{
lock (_mu)
{
_timer?.Dispose();
_timer = null;
}
}
/// <summary>Returns the current cached OCSP staple bytes, or <c>null</c> if none.</summary>
public byte[]? GetStaple()
{
lock (_mu)
{
return _staple.Response == null ? null : [.. _staple.Response];
}
}
}
/// <summary>
/// Interface for caching raw OCSP response bytes keyed by certificate fingerprint.
/// Mirrors Go <c>OCSPResponseCache</c> interface in server/ocsp_responsecache.go.
/// Replaces the stub in NatsServerTypes.cs.
/// </summary>
public interface IOcspResponseCache
{
/// <summary>Returns the cached OCSP response for <paramref name="key"/>, or <c>null</c>.</summary>
byte[]? Get(string key);
/// <summary>Stores an OCSP response under <paramref name="key"/>.</summary>
void Put(string key, byte[] response);
/// <summary>Removes the cached entry for <paramref name="key"/>.</summary>
void Remove(string key);
}
/// <summary>
/// Runtime counters for OCSP response cache behavior.
/// Mirrors Go <c>OCSPResponseCacheStats</c> shape.
/// </summary>
public sealed class OcspResponseCacheStats
{
public long Responses { get; set; }
public long Hits { get; set; }
public long Misses { get; set; }
public long Revokes { get; set; }
public long Goods { get; set; }
public long Unknowns { get; set; }
}
/// <summary>
/// A no-op OCSP cache that never stores anything.
/// Mirrors Go <c>NoOpCache</c> in server/ocsp_responsecache.go.
/// </summary>
internal sealed class NoOpCache : IOcspResponseCache
{
private readonly Lock _mu = new();
private readonly OcspResponseCacheConfig _config;
private OcspResponseCacheStats? _stats;
private bool _online;
public NoOpCache()
: this(new OcspResponseCacheConfig { Type = "none" })
{
}
public NoOpCache(OcspResponseCacheConfig config)
{
_config = config;
}
public byte[]? Get(string key) => null;
public void Put(string key, byte[] response) { }
public void Remove(string key) => Delete(key);
public void Delete(string key)
{
_ = key;
}
public void Start(NatsServer? server = null)
{
lock (_mu)
{
_stats = new OcspResponseCacheStats();
_online = true;
}
}
public void Stop(NatsServer? server = null)
{
lock (_mu)
{
_online = false;
}
}
public bool Online()
{
lock (_mu)
{
return _online;
}
}
public string Type() => "none";
public OcspResponseCacheConfig Config()
{
lock (_mu)
{
return _config;
}
}
public OcspResponseCacheStats? Stats()
{
lock (_mu)
{
if (_stats is null)
return null;
return new OcspResponseCacheStats
{
Responses = _stats.Responses,
Hits = _stats.Hits,
Misses = _stats.Misses,
Revokes = _stats.Revokes,
Goods = _stats.Goods,
Unknowns = _stats.Unknowns,
};
}
}
}
/// <summary>
/// An OCSP cache backed by a local directory on disk.
/// Mirrors Go <c>LocalCache</c> in server/ocsp_responsecache.go.
/// Full implementation is deferred to session 23.
/// </summary>
internal sealed class LocalDirCache : IOcspResponseCache
{
private readonly string _dir;
public LocalDirCache(string dir)
{
_dir = dir;
}
public byte[]? Get(string key)
{
var file = CacheFilePath(key);
if (!File.Exists(file))
return null;
return File.ReadAllBytes(file);
}
public void Put(string key, byte[] response)
{
ArgumentException.ThrowIfNullOrEmpty(key);
ArgumentNullException.ThrowIfNull(response);
Directory.CreateDirectory(_dir);
File.WriteAllBytes(CacheFilePath(key), response);
}
public void Remove(string key)
{
var file = CacheFilePath(key);
if (File.Exists(file))
File.Delete(file);
}
private string CacheFilePath(string key)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(key));
var file = Convert.ToHexString(hash).ToLowerInvariant();
return Path.Combine(_dir, $"{file}.ocsp");
}
}
/// <summary>
/// Payload for the OCSP peer certificate rejection advisory event.
/// Mirrors Go <c>OCSPPeerRejectEventMsg</c> fields in server/events.go
/// and the OCSP peer reject logic in server/ocsp_peer.go.
/// </summary>
public sealed class OcspPeerRejectInfo
{
[System.Text.Json.Serialization.JsonPropertyName("peer")]
public string Peer { get; set; } = string.Empty;
[System.Text.Json.Serialization.JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,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 parse utility functions in server/opts.go in the NATS server Go source.
using System.Net.Security;
using System.Security.Authentication;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
namespace ZB.MOM.NatsNet.Server.Config;
/// <summary>
/// Converts NATS duration strings (e.g. "2s", "100ms", "1h30m") to <see cref="TimeSpan"/>.
/// Mirrors Go <c>parseDuration</c> in server/opts.go.
/// </summary>
public sealed class NatsDurationJsonConverter : JsonConverter<TimeSpan>
{
private static readonly Regex Pattern = new(
@"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?(?:(\d+)ms)?(?:(\d+)us)?(?:(\d+)ns)?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString() ?? throw new JsonException("Expected a duration string");
return Parse(raw);
}
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
=> writer.WriteStringValue(FormatDuration(value));
/// <summary>
/// Parses a NATS-style duration string. Accepts Go time.Duration format strings and ISO 8601.
/// </summary>
public static TimeSpan Parse(string s)
{
if (string.IsNullOrWhiteSpace(s))
throw new FormatException("Duration string is empty");
// Try Go-style: e.g. "2s", "100ms", "1h30m", "5m10s"
var m = Pattern.Match(s);
if (m.Success && m.Value.Length > 0)
{
var hours = m.Groups[1].Success ? int.Parse(m.Groups[1].Value) : 0;
var minutes = m.Groups[2].Success ? int.Parse(m.Groups[2].Value) : 0;
var seconds = m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0;
var ms = m.Groups[4].Success ? int.Parse(m.Groups[4].Value) : 0;
var us = m.Groups[5].Success ? int.Parse(m.Groups[5].Value) : 0;
var ns = m.Groups[6].Success ? int.Parse(m.Groups[6].Value) : 0;
return new TimeSpan(0, hours, minutes, seconds, ms)
+ TimeSpan.FromMicroseconds(us)
+ TimeSpan.FromTicks(ns / 100); // 1 tick = 100 ns
}
// Try .NET TimeSpan.Parse (handles "hh:mm:ss")
if (TimeSpan.TryParse(s, out var ts)) return ts;
throw new FormatException($"Cannot parse duration string: \"{s}\"");
}
private static string FormatDuration(TimeSpan ts)
{
if (ts.TotalMilliseconds < 1) return $"{(long)ts.TotalNanoseconds}ns";
if (ts.TotalSeconds < 1) return $"{(long)ts.TotalMilliseconds}ms";
if (ts.TotalMinutes < 1) return $"{(long)ts.TotalSeconds}s";
if (ts.TotalHours < 1) return $"{ts.Minutes}m{ts.Seconds}s";
return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s";
}
}
/// <summary>
/// Converts a TLS version string ("1.2", "1.3", "TLS12") to <see cref="SslProtocols"/>.
/// Mirrors Go <c>parseTLSVersion</c> in server/opts.go.
/// </summary>
public sealed class TlsVersionJsonConverter : JsonConverter<SslProtocols>
{
public override SslProtocols Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString()?.Trim() ?? string.Empty;
return Parse(raw);
}
public override void Write(Utf8JsonWriter writer, SslProtocols value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString());
public static SslProtocols Parse(string s) => s.ToUpperInvariant() switch
{
"1.2" or "TLS12" or "TLSV1.2" => SslProtocols.Tls12,
"1.3" or "TLS13" or "TLSV1.3" => SslProtocols.Tls13,
_ => throw new FormatException($"Unknown TLS version: \"{s}\""),
};
}
/// <summary>
/// Validates and normalises a NATS URL string (nats://host:port).
/// Mirrors Go <c>parseURL</c> in server/opts.go.
/// </summary>
public sealed class NatsUrlJsonConverter : JsonConverter<string>
{
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString() ?? string.Empty;
return Normalise(raw);
}
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
=> writer.WriteStringValue(value);
public static string Normalise(string url)
{
if (string.IsNullOrWhiteSpace(url)) return url;
url = url.Trim();
if (!url.Contains("://")) url = "nats://" + url;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
throw new FormatException($"Invalid NATS URL: \"{url}\"");
return uri.ToString().TrimEnd('/');
}
}
/// <summary>
/// Converts a storage size string ("1GB", "512MB", "1024") to a byte count (long).
/// Mirrors Go <c>getStorageSize</c> in server/opts.go.
/// </summary>
public sealed class StorageSizeJsonConverter : JsonConverter<long>
{
private static readonly Regex Pattern = new(@"^(\d+(?:\.\d+)?)\s*([KMGT]?B?)?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Number)
{
return reader.GetInt64();
}
var raw = reader.GetString() ?? "0";
return Parse(raw);
}
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
=> writer.WriteNumberValue(value);
public static long Parse(string s)
{
// Mirrors Go getStorageSize: empty string returns 0 with no error.
if (string.IsNullOrWhiteSpace(s)) return 0;
if (long.TryParse(s, out var n)) return n;
var m = Pattern.Match(s.Trim());
if (!m.Success) throw new FormatException($"Invalid storage size: \"{s}\"");
var num = double.Parse(m.Groups[1].Value);
var suffix = m.Groups[2].Value.ToUpperInvariant();
return suffix switch
{
"K" or "KB" => (long)(num * 1024),
"M" or "MB" => (long)(num * 1024 * 1024),
"G" or "GB" => (long)(num * 1024 * 1024 * 1024),
"T" or "TB" => (long)(num * 1024L * 1024 * 1024 * 1024),
_ => (long)num,
};
}
}

View File

@@ -0,0 +1,996 @@
// Copyright 2017-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/reload.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server;
// =============================================================================
// IReloadOption — mirrors Go `option` interface in reload.go
// =============================================================================
/// <summary>
/// Represents a hot-swappable configuration setting that can be applied to a
/// running server. Mirrors Go <c>option</c> interface in server/reload.go.
/// </summary>
public interface IReloadOption
{
/// <summary>Apply this option to the running server.</summary>
void Apply(NatsServer server);
/// <summary>Returns true if this option requires reloading the logger.</summary>
bool IsLoggingChange();
/// <summary>
/// Returns true if this option requires reloading the cached trace level.
/// Clients store trace level separately.
/// </summary>
bool IsTraceLevelChange();
/// <summary>Returns true if this option requires reloading authorization.</summary>
bool IsAuthChange();
/// <summary>Returns true if this option requires reloading TLS.</summary>
bool IsTlsChange();
/// <summary>Returns true if this option requires reloading cluster permissions.</summary>
bool IsClusterPermsChange();
/// <summary>
/// Returns true if this option requires special handling for changes in
/// cluster pool size or accounts list.
/// </summary>
bool IsClusterPoolSizeOrAccountsChange();
/// <summary>
/// Returns true if this option indicates a change in the server's JetStream config.
/// Account changes are handled separately in reloadAuthorization.
/// </summary>
bool IsJetStreamChange();
/// <summary>Returns true if this change requires publishing the server's statz.</summary>
bool IsStatszChange();
}
// =============================================================================
// NoopReloadOption — mirrors Go `noopOption` struct in reload.go
// =============================================================================
/// <summary>
/// Base class providing no-op implementations for all <see cref="IReloadOption"/>
/// methods. Concrete option types override only the methods relevant to them.
/// Mirrors Go <c>noopOption</c> struct in server/reload.go.
/// </summary>
public abstract class NoopReloadOption : IReloadOption
{
/// <inheritdoc/>
public virtual void Apply(NatsServer server) { }
/// <inheritdoc/>
public virtual bool IsLoggingChange() => false;
/// <inheritdoc/>
public virtual bool IsTraceLevelChange() => false;
/// <inheritdoc/>
public virtual bool IsAuthChange() => false;
/// <inheritdoc/>
public virtual bool IsTlsChange() => false;
/// <inheritdoc/>
public virtual bool IsClusterPermsChange() => false;
/// <inheritdoc/>
public virtual bool IsClusterPoolSizeOrAccountsChange() => false;
/// <inheritdoc/>
public virtual bool IsJetStreamChange() => false;
/// <inheritdoc/>
public virtual bool IsStatszChange() => false;
}
// =============================================================================
// Intermediate base classes (mirrors Go loggingOption / traceLevelOption)
// =============================================================================
/// <summary>
/// Base for all logging-related reload options.
/// Mirrors Go <c>loggingOption</c> struct.
/// </summary>
internal abstract class LoggingReloadOption : NoopReloadOption
{
public override bool IsLoggingChange() => true;
}
/// <summary>
/// Base for all trace-level reload options.
/// Mirrors Go <c>traceLevelOption</c> struct.
/// </summary>
internal abstract class TraceLevelReloadOption : LoggingReloadOption
{
public override bool IsTraceLevelChange() => true;
}
/// <summary>
/// Base for all authorization-related reload options.
/// Mirrors Go <c>authOption</c> struct.
/// </summary>
internal abstract class AuthReloadOption : NoopReloadOption
{
public override bool IsAuthChange() => true;
}
/// <summary>
/// Base for TLS reload options.
/// Mirrors Go <c>tlsOption</c> (as a base, not the concrete type).
/// </summary>
internal abstract class TlsBaseReloadOption : NoopReloadOption
{
public override bool IsTlsChange() => true;
}
// =============================================================================
// Logging & Trace option types
// =============================================================================
/// <summary>
/// Reload option for the <c>trace</c> setting.
/// Mirrors Go <c>traceOption</c> struct in reload.go.
/// </summary>
internal sealed class TraceReloadOption : TraceLevelReloadOption
{
private readonly bool _newValue;
public TraceReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: trace = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>trace_verbose</c> setting.
/// Mirrors Go <c>traceVerboseOption</c> struct in reload.go.
/// </summary>
internal sealed class TraceVerboseReloadOption : TraceLevelReloadOption
{
private readonly bool _newValue;
public TraceVerboseReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: trace_verbose = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>trace_headers</c> setting.
/// Mirrors Go <c>traceHeadersOption</c> struct in reload.go.
/// </summary>
internal sealed class TraceHeadersReloadOption : TraceLevelReloadOption
{
private readonly bool _newValue;
public TraceHeadersReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: trace_headers = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>debug</c> setting.
/// Mirrors Go <c>debugOption</c> struct in reload.go.
/// </summary>
internal sealed class DebugReloadOption : LoggingReloadOption
{
private readonly bool _newValue;
public DebugReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
server.Noticef("Reloaded: debug = {0}", _newValue);
// TODO: session 13 — call server.ReloadDebugRaftNodes(_newValue)
}
}
/// <summary>
/// Reload option for the <c>logtime</c> setting.
/// Mirrors Go <c>logtimeOption</c> struct in reload.go.
/// </summary>
internal sealed class LogtimeReloadOption : LoggingReloadOption
{
private readonly bool _newValue;
public LogtimeReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: logtime = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>logtime_utc</c> setting.
/// Mirrors Go <c>logtimeUTCOption</c> struct in reload.go.
/// </summary>
internal sealed class LogtimeUtcReloadOption : LoggingReloadOption
{
private readonly bool _newValue;
public LogtimeUtcReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: logtime_utc = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>log_file</c> setting.
/// Mirrors Go <c>logfileOption</c> struct in reload.go.
/// </summary>
internal sealed class LogFileReloadOption : LoggingReloadOption
{
private readonly string _newValue;
public LogFileReloadOption(string newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: log_file = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>syslog</c> setting.
/// Mirrors Go <c>syslogOption</c> struct in reload.go.
/// </summary>
internal sealed class SyslogReloadOption : LoggingReloadOption
{
private readonly bool _newValue;
public SyslogReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: syslog = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>remote_syslog</c> setting.
/// Mirrors Go <c>remoteSyslogOption</c> struct in reload.go.
/// </summary>
internal sealed class RemoteSyslogReloadOption : LoggingReloadOption
{
private readonly string _newValue;
public RemoteSyslogReloadOption(string newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: remote_syslog = {0}", _newValue);
}
// =============================================================================
// TLS option types
// =============================================================================
/// <summary>
/// Reload option for the <c>tls</c> setting.
/// Mirrors Go <c>tlsOption</c> struct in reload.go.
/// The TLS config is stored as <c>object?</c> because the full
/// <c>TlsConfig</c> type is not yet ported.
/// TODO: session 13 — replace object? with the ported TlsConfig type.
/// </summary>
internal sealed class TlsReloadOption : NoopReloadOption
{
// TODO: session 13 — replace object? with ported TlsConfig type
private readonly object? _newValue;
public TlsReloadOption(object? newValue) => _newValue = newValue;
public override bool IsTlsChange() => true;
public override void Apply(NatsServer server)
{
var message = _newValue is null ? "disabled" : "enabled";
server.Noticef("Reloaded: tls = {0}", message);
// TODO: session 13 — update server.Info.TLSRequired / TLSVerify
}
}
/// <summary>
/// Reload option for the TLS <c>timeout</c> setting.
/// Mirrors Go <c>tlsTimeoutOption</c> struct in reload.go.
/// </summary>
internal sealed class TlsTimeoutReloadOption : NoopReloadOption
{
private readonly double _newValue;
public TlsTimeoutReloadOption(double newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: tls timeout = {0}", _newValue);
}
/// <summary>
/// Reload option for the TLS <c>pinned_certs</c> setting.
/// Mirrors Go <c>tlsPinnedCertOption</c> struct in reload.go.
/// The pinned cert set is stored as <c>object?</c> pending the port
/// of the PinnedCertSet type.
/// TODO: session 13 — replace object? with ported PinnedCertSet type.
/// </summary>
internal sealed class TlsPinnedCertReloadOption : NoopReloadOption
{
// TODO: session 13 — replace object? with ported PinnedCertSet type
private readonly object? _newValue;
public TlsPinnedCertReloadOption(object? newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: pinned_certs");
}
/// <summary>
/// Reload option for the TLS <c>handshake_first</c> setting.
/// Mirrors Go <c>tlsHandshakeFirst</c> struct in reload.go.
/// </summary>
internal sealed class TlsHandshakeFirstReloadOption : NoopReloadOption
{
private readonly bool _newValue;
public TlsHandshakeFirstReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: Client TLS handshake first: {0}", _newValue);
}
/// <summary>
/// Reload option for the TLS <c>handshake_first_fallback</c> delay setting.
/// Mirrors Go <c>tlsHandshakeFirstFallback</c> struct in reload.go.
/// </summary>
internal sealed class TlsHandshakeFirstFallbackReloadOption : NoopReloadOption
{
private readonly TimeSpan _newValue;
public TlsHandshakeFirstFallbackReloadOption(TimeSpan newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: Client TLS handshake first fallback delay: {0}", _newValue);
}
// =============================================================================
// Authorization option types
// =============================================================================
/// <summary>
/// Reload option for the <c>username</c> authorization setting.
/// Mirrors Go <c>usernameOption</c> struct in reload.go.
/// </summary>
internal sealed class UsernameReloadOption : AuthReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: authorization username");
}
/// <summary>
/// Reload option for the <c>password</c> authorization setting.
/// Mirrors Go <c>passwordOption</c> struct in reload.go.
/// </summary>
internal sealed class PasswordReloadOption : AuthReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: authorization password");
}
/// <summary>
/// Reload option for the <c>token</c> authorization setting.
/// Mirrors Go <c>authorizationOption</c> struct in reload.go.
/// </summary>
internal sealed class AuthorizationReloadOption : AuthReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: authorization token");
}
/// <summary>
/// Reload option for the authorization <c>timeout</c> setting.
/// Note: this is a NoopReloadOption (not auth) because authorization
/// will be reloaded with options separately.
/// Mirrors Go <c>authTimeoutOption</c> struct in reload.go.
/// </summary>
internal sealed class AuthTimeoutReloadOption : NoopReloadOption
{
private readonly double _newValue;
public AuthTimeoutReloadOption(double newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: authorization timeout = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>tags</c> setting.
/// Mirrors Go <c>tagsOption</c> struct in reload.go.
/// </summary>
internal sealed class TagsReloadOption : NoopReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: tags");
public override bool IsStatszChange() => true;
}
/// <summary>
/// Reload option for the <c>metadata</c> setting.
/// Mirrors Go <c>metadataOption</c> struct in reload.go.
/// </summary>
internal sealed class MetadataReloadOption : NoopReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: metadata");
public override bool IsStatszChange() => true;
}
/// <summary>
/// Reload option for the authorization <c>users</c> setting.
/// Mirrors Go <c>usersOption</c> struct in reload.go.
/// </summary>
internal sealed class UsersReloadOption : AuthReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: authorization users");
}
/// <summary>
/// Reload option for the authorization <c>nkeys</c> setting.
/// Mirrors Go <c>nkeysOption</c> struct in reload.go.
/// </summary>
internal sealed class NkeysReloadOption : AuthReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: authorization nkey users");
}
/// <summary>
/// Reload option for the <c>accounts</c> setting.
/// Mirrors Go <c>accountsOption</c> struct in reload.go.
/// </summary>
internal sealed class AccountsReloadOption : AuthReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: accounts");
}
// =============================================================================
// Cluster option types
// =============================================================================
/// <summary>
/// Reload option for the <c>cluster</c> setting.
/// Stores cluster options as <c>object?</c> pending the port of <c>ClusterOpts</c>.
/// Mirrors Go <c>clusterOption</c> struct in reload.go.
/// TODO: session 13 — replace object? with ported ClusterOpts type.
/// </summary>
internal sealed class ClusterReloadOption : AuthReloadOption
{
// TODO: session 13 — replace object? with ported ClusterOpts type
private readonly object? _newValue;
private readonly bool _permsChanged;
private readonly bool _poolSizeChanged;
private readonly bool _compressChanged;
private readonly string[] _accsAdded;
private readonly string[] _accsRemoved;
public ClusterReloadOption(
object? newValue,
bool permsChanged,
bool poolSizeChanged,
bool compressChanged,
string[] accsAdded,
string[] accsRemoved)
{
_newValue = newValue;
_permsChanged = permsChanged;
_poolSizeChanged = poolSizeChanged;
_compressChanged = compressChanged;
_accsAdded = accsAdded;
_accsRemoved = accsRemoved;
}
public override bool IsClusterPermsChange()
=> _permsChanged;
public override bool IsClusterPoolSizeOrAccountsChange()
=> _poolSizeChanged || _accsAdded.Length > 0 || _accsRemoved.Length > 0;
public override void Apply(NatsServer server)
{
// TODO: session 13 — full cluster apply logic (TLS, route info, compression)
server.Noticef("Reloaded: cluster");
}
}
/// <summary>
/// Reload option for the cluster <c>routes</c> setting.
/// Routes to add/remove are stored as <c>object[]</c> pending the port of URL handling.
/// Mirrors Go <c>routesOption</c> struct in reload.go.
/// TODO: session 13 — replace object[] with Uri[] when route types are ported.
/// </summary>
internal sealed class RoutesReloadOption : NoopReloadOption
{
// TODO: session 13 — replace object[] with Uri[] when route URL types are ported
private readonly object[] _add;
private readonly object[] _remove;
public RoutesReloadOption(object[] add, object[] remove)
{
_add = add;
_remove = remove;
}
public override void Apply(NatsServer server)
{
// TODO: session 13 — add/remove routes, update varzUpdateRouteURLs
server.Noticef("Reloaded: cluster routes");
}
}
// =============================================================================
// Connection limit & network option types
// =============================================================================
/// <summary>
/// Reload option for the <c>max_connections</c> setting.
/// Mirrors Go <c>maxConnOption</c> struct in reload.go.
/// </summary>
internal sealed class MaxConnReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MaxConnReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — close random connections if over limit
server.Noticef("Reloaded: max_connections = {0}", _newValue);
}
}
/// <summary>
/// Reload option for the <c>pid_file</c> setting.
/// Mirrors Go <c>pidFileOption</c> struct in reload.go.
/// </summary>
internal sealed class PidFileReloadOption : NoopReloadOption
{
private readonly string _newValue;
public PidFileReloadOption(string newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
if (string.IsNullOrEmpty(_newValue))
return;
// TODO: session 13 — call server.LogPid()
server.Noticef("Reloaded: pid_file = {0}", _newValue);
}
}
/// <summary>
/// Reload option for the <c>ports_file_dir</c> setting.
/// Mirrors Go <c>portsFileDirOption</c> struct in reload.go.
/// </summary>
internal sealed class PortsFileDirReloadOption : NoopReloadOption
{
private readonly string _oldValue;
private readonly string _newValue;
public PortsFileDirReloadOption(string oldValue, string newValue)
{
_oldValue = oldValue;
_newValue = newValue;
}
public override void Apply(NatsServer server)
{
// TODO: session 13 — call server.DeletePortsFile(_oldValue) and server.LogPorts()
server.Noticef("Reloaded: ports_file_dir = {0}", _newValue);
}
}
/// <summary>
/// Reload option for the <c>max_control_line</c> setting.
/// Mirrors Go <c>maxControlLineOption</c> struct in reload.go.
/// </summary>
internal sealed class MaxControlLineReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MaxControlLineReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — update mcl on each connected client
server.Noticef("Reloaded: max_control_line = {0}", _newValue);
}
}
/// <summary>
/// Reload option for the <c>max_payload</c> setting.
/// Mirrors Go <c>maxPayloadOption</c> struct in reload.go.
/// </summary>
internal sealed class MaxPayloadReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MaxPayloadReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — update server info and mpay on each client
server.Noticef("Reloaded: max_payload = {0}", _newValue);
}
}
/// <summary>
/// Reload option for the <c>ping_interval</c> setting.
/// Mirrors Go <c>pingIntervalOption</c> struct in reload.go.
/// </summary>
internal sealed class PingIntervalReloadOption : NoopReloadOption
{
private readonly TimeSpan _newValue;
public PingIntervalReloadOption(TimeSpan newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: ping_interval = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>ping_max</c> setting.
/// Mirrors Go <c>maxPingsOutOption</c> struct in reload.go.
/// </summary>
internal sealed class MaxPingsOutReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MaxPingsOutReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: ping_max = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>write_deadline</c> setting.
/// Mirrors Go <c>writeDeadlineOption</c> struct in reload.go.
/// </summary>
internal sealed class WriteDeadlineReloadOption : NoopReloadOption
{
private readonly TimeSpan _newValue;
public WriteDeadlineReloadOption(TimeSpan newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: write_deadline = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>client_advertise</c> setting.
/// Mirrors Go <c>clientAdvertiseOption</c> struct in reload.go.
/// </summary>
internal sealed class ClientAdvertiseReloadOption : NoopReloadOption
{
private readonly string _newValue;
public ClientAdvertiseReloadOption(string newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — call server.SetInfoHostPort()
server.Noticef("Reload: client_advertise = {0}", _newValue);
}
}
// =============================================================================
// JetStream option type
// =============================================================================
/// <summary>
/// Reload option for the <c>jetstream</c> setting.
/// Mirrors Go <c>jetStreamOption</c> struct in reload.go.
/// </summary>
internal sealed class JetStreamReloadOption : NoopReloadOption
{
private readonly bool _newValue;
public JetStreamReloadOption(bool newValue) => _newValue = newValue;
public override bool IsJetStreamChange() => true;
public override bool IsStatszChange() => true;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: JetStream");
}
// =============================================================================
// Miscellaneous option types
// =============================================================================
/// <summary>
/// Reload option for the <c>default_sentinel</c> setting.
/// Mirrors Go <c>defaultSentinelOption</c> struct in reload.go.
/// </summary>
internal sealed class DefaultSentinelReloadOption : NoopReloadOption
{
private readonly string _newValue;
public DefaultSentinelReloadOption(string newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: default_sentinel = {0}", _newValue);
}
/// <summary>
/// Reload option for the OCSP setting.
/// The new value is stored as <c>object?</c> pending the port of <c>OCSPConfig</c>.
/// Mirrors Go <c>ocspOption</c> struct in reload.go.
/// TODO: session 13 — replace object? with ported OcspConfig type.
/// </summary>
internal sealed class OcspReloadOption : TlsBaseReloadOption
{
// TODO: session 13 — replace object? with ported OcspConfig type
private readonly object? _newValue;
public OcspReloadOption(object? newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: OCSP");
}
/// <summary>
/// Reload option for the OCSP response cache setting.
/// The new value is stored as <c>object?</c> pending the port of
/// <c>OCSPResponseCacheConfig</c>.
/// Mirrors Go <c>ocspResponseCacheOption</c> struct in reload.go.
/// TODO: session 13 — replace object? with ported OcspResponseCacheConfig type.
/// </summary>
internal sealed class OcspResponseCacheReloadOption : TlsBaseReloadOption
{
// TODO: session 13 — replace object? with ported OcspResponseCacheConfig type
private readonly object? _newValue;
public OcspResponseCacheReloadOption(object? newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded OCSP peer cache");
}
/// <summary>
/// Reload option for the <c>connect_error_reports</c> setting.
/// Mirrors Go <c>connectErrorReports</c> struct in reload.go.
/// </summary>
internal sealed class ConnectErrorReportsReloadOption : NoopReloadOption
{
private readonly int _newValue;
public ConnectErrorReportsReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: connect_error_reports = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>reconnect_error_reports</c> setting.
/// Mirrors Go <c>reconnectErrorReports</c> struct in reload.go.
/// </summary>
internal sealed class ReconnectErrorReportsReloadOption : NoopReloadOption
{
private readonly int _newValue;
public ReconnectErrorReportsReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: reconnect_error_reports = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>max_traced_msg_len</c> setting.
/// Mirrors Go <c>maxTracedMsgLenOption</c> struct in reload.go.
/// </summary>
internal sealed class MaxTracedMsgLenReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MaxTracedMsgLenReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — update server.Opts.MaxTracedMsgLen under lock
server.Noticef("Reloaded: max_traced_msg_len = {0}", _newValue);
}
}
// =============================================================================
// MQTT option types
// =============================================================================
/// <summary>
/// Reload option for the MQTT <c>ack_wait</c> setting.
/// Mirrors Go <c>mqttAckWaitReload</c> struct in reload.go.
/// </summary>
internal sealed class MqttAckWaitReloadOption : NoopReloadOption
{
private readonly TimeSpan _newValue;
public MqttAckWaitReloadOption(TimeSpan newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: MQTT ack_wait = {0}", _newValue);
}
/// <summary>
/// Reload option for the MQTT <c>max_ack_pending</c> setting.
/// Mirrors Go <c>mqttMaxAckPendingReload</c> struct in reload.go.
/// </summary>
internal sealed class MqttMaxAckPendingReloadOption : NoopReloadOption
{
private readonly ushort _newValue;
public MqttMaxAckPendingReloadOption(ushort newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — call server.MqttUpdateMaxAckPending(_newValue)
server.Noticef("Reloaded: MQTT max_ack_pending = {0}", _newValue);
}
}
/// <summary>
/// Reload option for the MQTT <c>stream_replicas</c> setting.
/// Mirrors Go <c>mqttStreamReplicasReload</c> struct in reload.go.
/// </summary>
internal sealed class MqttStreamReplicasReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MqttStreamReplicasReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: MQTT stream_replicas = {0}", _newValue);
}
/// <summary>
/// Reload option for the MQTT <c>consumer_replicas</c> setting.
/// Mirrors Go <c>mqttConsumerReplicasReload</c> struct in reload.go.
/// </summary>
internal sealed class MqttConsumerReplicasReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MqttConsumerReplicasReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: MQTT consumer_replicas = {0}", _newValue);
}
/// <summary>
/// Reload option for the MQTT <c>consumer_memory_storage</c> setting.
/// Mirrors Go <c>mqttConsumerMemoryStorageReload</c> struct in reload.go.
/// </summary>
internal sealed class MqttConsumerMemoryStorageReloadOption : NoopReloadOption
{
private readonly bool _newValue;
public MqttConsumerMemoryStorageReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: MQTT consumer_memory_storage = {0}", _newValue);
}
/// <summary>
/// Reload option for the MQTT <c>consumer_inactive_threshold</c> setting.
/// Mirrors Go <c>mqttInactiveThresholdReload</c> struct in reload.go.
/// </summary>
internal sealed class MqttInactiveThresholdReloadOption : NoopReloadOption
{
private readonly TimeSpan _newValue;
public MqttInactiveThresholdReloadOption(TimeSpan newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: MQTT consumer_inactive_threshold = {0}", _newValue);
}
// =============================================================================
// Profiling option type
// =============================================================================
/// <summary>
/// Reload option for the <c>prof_block_rate</c> setting.
/// Mirrors Go <c>profBlockRateReload</c> struct in reload.go.
/// </summary>
internal sealed class ProfBlockRateReloadOption : NoopReloadOption
{
private readonly int _newValue;
public ProfBlockRateReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — call server.SetBlockProfileRate(_newValue)
server.Noticef("Reloaded: prof_block_rate = {0}", _newValue);
}
}
// =============================================================================
// LeafNode option type
// =============================================================================
/// <summary>
/// Reload option for leaf-node settings (TLS handshake-first, compression, disabled).
/// Mirrors Go <c>leafNodeOption</c> struct in reload.go.
/// </summary>
internal sealed class LeafNodeReloadOption : NoopReloadOption
{
private readonly bool _tlsFirstChanged;
private readonly bool _compressionChanged;
private readonly bool _disabledChanged;
public LeafNodeReloadOption(bool tlsFirstChanged, bool compressionChanged, bool disabledChanged)
{
_tlsFirstChanged = tlsFirstChanged;
_compressionChanged = compressionChanged;
_disabledChanged = disabledChanged;
}
public override void Apply(NatsServer server)
{
// TODO: session 13 — full leaf-node apply logic from Go leafNodeOption.Apply()
if (_tlsFirstChanged)
server.Noticef("Reloaded: LeafNode TLS HandshakeFirst settings");
if (_compressionChanged)
server.Noticef("Reloaded: LeafNode compression settings");
if (_disabledChanged)
server.Noticef("Reloaded: LeafNode disabled/enabled state");
}
}
// =============================================================================
// NoFastProducerStall option type
// =============================================================================
/// <summary>
/// Reload option for the <c>no_fast_producer_stall</c> setting.
/// Mirrors Go <c>noFastProdStallReload</c> struct in reload.go.
/// </summary>
internal sealed class NoFastProducerStallReloadOption : NoopReloadOption
{
private readonly bool _noStall;
public NoFastProducerStallReloadOption(bool noStall) => _noStall = noStall;
public override void Apply(NatsServer server)
{
var not = _noStall ? "not " : string.Empty;
server.Noticef("Reloaded: fast producers will {0}be stalled", not);
}
}
// =============================================================================
// Proxies option type
// =============================================================================
/// <summary>
/// Reload option for the <c>proxies</c> trusted keys setting.
/// Mirrors Go <c>proxiesReload</c> struct in reload.go.
/// </summary>
internal sealed class ProxiesReloadOption : NoopReloadOption
{
private readonly string[] _add;
private readonly string[] _del;
public ProxiesReloadOption(string[] add, string[] del)
{
_add = add;
_del = del;
}
public override void Apply(NatsServer server)
{
// TODO: session 13 — disconnect proxied clients for removed keys,
// call server.ProcessProxiesTrustedKeys()
if (_del.Length > 0)
server.Noticef("Reloaded: proxies trusted keys {0} were removed", string.Join(", ", _del));
if (_add.Length > 0)
server.Noticef("Reloaded: proxies trusted keys {0} were added", string.Join(", ", _add));
}
}
// =============================================================================
// ConfigReloader — stub for server/reload.go Reload() / ReloadOptions()
// =============================================================================
/// <summary>
/// Stub for the configuration reloader.
/// Full reload logic (diffOptions, applyOptions, recheckPinnedCerts) will be
/// implemented in a future session.
/// Mirrors Go <c>Server.Reload()</c> and <c>Server.ReloadOptions()</c> in
/// server/reload.go.
/// </summary>
internal sealed class ConfigReloader
{
// TODO: session 13 — full reload logic
// Mirrors Go server.Reload() / server.ReloadOptions() in server/reload.go
/// <summary>
/// Stub: read and apply the server config file.
/// Returns null on success; a non-null Exception describes the failure.
/// </summary>
public Exception? Reload(NatsServer server) => null;
}

View File

@@ -0,0 +1,132 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/opts.go in the NATS server Go source.
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.NatsNet.Server.Config;
/// <summary>
/// Loads and binds NATS server configuration from an <c>appsettings.json</c> file.
/// Replaces the Go <c>processConfigFile</c> / <c>processConfigFileLine</c> pipeline
/// and all <c>parse*</c> helper functions in server/opts.go.
/// </summary>
public static class ServerOptionsConfiguration
{
/// <summary>
/// Creates a <see cref="JsonSerializerOptions"/> instance pre-configured with all
/// NATS-specific JSON converters.
/// </summary>
public static JsonSerializerOptions CreateJsonOptions()
{
var opts = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
opts.Converters.Add(new NatsDurationJsonConverter());
opts.Converters.Add(new TlsVersionJsonConverter());
opts.Converters.Add(new StorageSizeJsonConverter());
return opts;
}
/// <summary>
/// Reads a JSON file at <paramref name="path"/> and returns a bound
/// <see cref="ServerOptions"/> instance.
/// Mirrors Go <c>ProcessConfigFile</c> and <c>Options.ProcessConfigFile</c>.
/// </summary>
public static ServerOptions ProcessConfigFile(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Configuration file not found: {path}", path);
var json = File.ReadAllText(path, Encoding.UTF8);
return ProcessConfigString(json);
}
/// <summary>
/// Deserialises a JSON string and returns a bound <see cref="ServerOptions"/> instance.
/// Mirrors Go <c>Options.ProcessConfigString</c>.
/// </summary>
public static ServerOptions ProcessConfigString(string json)
{
ArgumentNullException.ThrowIfNullOrEmpty(json);
var opts = JsonSerializer.Deserialize<ServerOptions>(json, CreateJsonOptions())
?? new ServerOptions();
PostProcess(opts);
return opts;
}
/// <summary>
/// Binds a pre-built <see cref="IConfiguration"/> (e.g. from an ASP.NET Core host)
/// to a <see cref="ServerOptions"/> instance.
/// The configuration section should be the root or a named section such as "NatsServer".
/// </summary>
public static void BindConfiguration(IConfiguration config, ServerOptions target)
{
ArgumentNullException.ThrowIfNull(config);
ArgumentNullException.ThrowIfNull(target);
config.Bind(target);
PostProcess(target);
}
// -------------------------------------------------------------------------
// Post-processing
// -------------------------------------------------------------------------
/// <summary>
/// Applies defaults and cross-field validation after loading.
/// Mirrors the end of <c>Options.processConfigFile</c> and
/// <c>configureSystemAccount</c> in server/opts.go.
/// </summary>
private static void PostProcess(ServerOptions opts)
{
// Apply default port if not set.
if (opts.Port == 0) opts.Port = ServerConstants.DefaultPort;
// Apply default host if not set.
if (string.IsNullOrEmpty(opts.Host)) opts.Host = ServerConstants.DefaultHost;
// Apply default max payload.
if (opts.MaxPayload == 0) opts.MaxPayload = ServerConstants.MaxPayload;
// Apply default auth timeout.
if (opts.AuthTimeout == 0) opts.AuthTimeout = ServerConstants.DefaultAuthTimeout;
// Apply default max control line size.
if (opts.MaxControlLine == 0) opts.MaxControlLine = ServerConstants.MaxControlLineSize;
// Ensure SystemAccount defaults if not set.
ConfigureSystemAccount(opts);
}
/// <summary>
/// Sets up the system account name from options.
/// Mirrors Go <c>configureSystemAccount</c> in server/opts.go.
/// </summary>
private static void ConfigureSystemAccount(ServerOptions opts)
{
// If system account already set, nothing to do.
if (!string.IsNullOrEmpty(opts.SystemAccount)) return;
// Respect explicit opt-out.
if (opts.NoSystemAccount) return;
// Default to "$SYS" if not explicitly disabled.
opts.SystemAccount = ServerConstants.DefaultSystemAccount;
}
}

View File

@@ -0,0 +1,778 @@
// Copyright 2018-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/events.go in the NATS server Go source.
using System.Text.Json.Serialization;
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// System subject constants
// Mirrors the const block at the top of server/events.go.
// ============================================================================
/// <summary>
/// System-account subject templates and constants used for internal NATS server
/// event routing. All format-string fields use <see cref="string.Format"/> with
/// the appropriate server/account ID substituted at call time.
/// Mirrors the const block in server/events.go.
/// </summary>
public static class SystemSubjects
{
// Account lookup / claims
public const string AccLookupReqSubj = "$SYS.REQ.ACCOUNT.{0}.CLAIMS.LOOKUP";
public const string AccPackReqSubj = "$SYS.REQ.CLAIMS.PACK";
public const string AccListReqSubj = "$SYS.REQ.CLAIMS.LIST";
public const string AccClaimsReqSubj = "$SYS.REQ.CLAIMS.UPDATE";
public const string AccDeleteReqSubj = "$SYS.REQ.CLAIMS.DELETE";
// Connection events
public const string ConnectEventSubj = "$SYS.ACCOUNT.{0}.CONNECT";
public const string DisconnectEventSubj = "$SYS.ACCOUNT.{0}.DISCONNECT";
// Direct request routing
public const string AccDirectReqSubj = "$SYS.REQ.ACCOUNT.{0}.{1}";
public const string AccPingReqSubj = "$SYS.REQ.ACCOUNT.PING.{0}";
// Account update events (both old and new forms kept for backward compatibility)
public const string AccUpdateEventSubjOld = "$SYS.ACCOUNT.{0}.CLAIMS.UPDATE";
public const string AccUpdateEventSubjNew = "$SYS.REQ.ACCOUNT.{0}.CLAIMS.UPDATE";
public const string ConnsRespSubj = "$SYS._INBOX_.{0}";
public const string AccConnsEventSubjNew = "$SYS.ACCOUNT.{0}.SERVER.CONNS";
public const string AccConnsEventSubjOld = "$SYS.SERVER.ACCOUNT.{0}.CONNS"; // backward compat
// Server lifecycle events
public const string LameDuckEventSubj = "$SYS.SERVER.{0}.LAMEDUCK";
public const string ShutdownEventSubj = "$SYS.SERVER.{0}.SHUTDOWN";
// Client control
public const string ClientKickReqSubj = "$SYS.REQ.SERVER.{0}.KICK";
public const string ClientLdmReqSubj = "$SYS.REQ.SERVER.{0}.LDM";
// Auth error events
public const string AuthErrorEventSubj = "$SYS.SERVER.{0}.CLIENT.AUTH.ERR";
public const string AuthErrorAccountEventSubj = "$SYS.ACCOUNT.CLIENT.AUTH.ERR";
// Stats
public const string ServerStatsSubj = "$SYS.SERVER.{0}.STATSZ";
public const string ServerDirectReqSubj = "$SYS.REQ.SERVER.{0}.{1}";
public const string ServerPingReqSubj = "$SYS.REQ.SERVER.PING.{0}";
public const string ServerStatsPingReqSubj = "$SYS.REQ.SERVER.PING"; // deprecated; use STATSZ variant
public const string ServerReloadReqSubj = "$SYS.REQ.SERVER.{0}.RELOAD";
// Leaf node
public const string LeafNodeConnectEventSubj = "$SYS.ACCOUNT.{0}.LEAFNODE.CONNECT"; // internal only
// Latency
public const string RemoteLatencyEventSubj = "$SYS.LATENCY.M2.{0}";
public const string InboxRespSubj = "$SYS._INBOX.{0}.{1}";
// User info
public const string UserDirectInfoSubj = "$SYS.REQ.USER.INFO";
public const string UserDirectReqSubj = "$SYS.REQ.USER.{0}.INFO";
// Subscription count
public const string AccNumSubsReqSubj = "$SYS.REQ.ACCOUNT.NSUBS";
// Debug
public const string AccSubsSubj = "$SYS.DEBUG.SUBSCRIBERS";
// OCSP peer events
public const string OcspPeerRejectEventSubj = "$SYS.SERVER.{0}.OCSP.PEER.CONN.REJECT";
public const string OcspPeerChainlinkInvalidEventSubj = "$SYS.SERVER.{0}.OCSP.PEER.LINK.INVALID";
// Parsing constants (token indexes / counts)
public const int AccLookupReqTokens = 6;
public const int ShutdownEventTokens = 4;
public const int ServerSubjectIndex = 2;
public const int AccUpdateTokensNew = 6;
public const int AccUpdateTokensOld = 5;
public const int AccUpdateAccIdxOld = 2;
public const int AccReqTokens = 5;
public const int AccReqAccIndex = 3;
}
// ============================================================================
// Advisory message type schema URI constants
// Mirrors the const string variables near each struct in server/events.go.
// ============================================================================
public static class EventMsgTypes
{
public const string ConnectEventMsgType = "io.nats.server.advisory.v1.client_connect";
public const string DisconnectEventMsgType = "io.nats.server.advisory.v1.client_disconnect";
public const string OcspPeerRejectEventMsgType = "io.nats.server.advisory.v1.ocsp_peer_reject";
public const string OcspPeerChainlinkInvalidEventMsgType = "io.nats.server.advisory.v1.ocsp_peer_link_invalid";
public const string AccountNumConnsMsgType = "io.nats.server.advisory.v1.account_connections";
}
// ============================================================================
// Heartbeat / rate-limit intervals (mirrors package-level vars in events.go)
// ============================================================================
/// <summary>
/// Default timing constants for server event heartbeats and rate limiting.
/// Mirrors Go package-level <c>var</c> declarations in events.go.
/// </summary>
public static class EventIntervals
{
/// <summary>Default HB interval for events. Mirrors Go <c>eventsHBInterval = 30s</c>.</summary>
public static readonly TimeSpan EventsHbInterval = TimeSpan.FromSeconds(30);
/// <summary>Default HB interval for stats. Mirrors Go <c>statsHBInterval = 10s</c>.</summary>
public static readonly TimeSpan StatsHbInterval = TimeSpan.FromSeconds(10);
/// <summary>Minimum interval between statsz publishes. Mirrors Go <c>defaultStatszRateLimit = 1s</c>.</summary>
public static readonly TimeSpan DefaultStatszRateLimit = TimeSpan.FromSeconds(1);
}
// ============================================================================
// SysMsgHandler — delegate for internal system message dispatch
// Mirrors Go <c>sysMsgHandler</c> func type in events.go.
// ============================================================================
/// <summary>
/// Callback invoked when an internal system message is dispatched.
/// Mirrors Go <c>sysMsgHandler</c> in server/events.go.
/// </summary>
public delegate void SysMsgHandler(
Subscription sub,
NatsClient client,
Account acc,
string subject,
string reply,
byte[] hdr,
byte[] msg);
// ============================================================================
// InSysMsg — queued internal system message
// Mirrors Go <c>inSysMsg</c> struct in server/events.go.
// ============================================================================
/// <summary>
/// Holds a system message queued for internal delivery, avoiding the
/// route/gateway path.
/// Mirrors Go <c>inSysMsg</c> struct in server/events.go.
/// </summary>
internal sealed class InSysMsg
{
public Subscription? Sub { get; set; }
public NatsClient? Client { get; set; }
public Account? Acc { get; set; }
public string Subject { get; set; } = string.Empty;
public string Reply { get; set; } = string.Empty;
public byte[]? Hdr { get; set; }
public byte[]? Msg { get; set; }
public SysMsgHandler? Cb { get; set; }
}
// ============================================================================
// InternalState — server internal/sys state
// Mirrors Go <c>internal</c> struct in server/events.go.
// Uses Monitor lock (lock(this)) in place of Go's embedded sync.Mutex.
// ============================================================================
/// <summary>
/// Holds all internal state used by the server's system-account event
/// machinery: account reference, client, send/receive queues, timers,
/// reply handlers, and heartbeat configuration.
/// Mirrors Go <c>internal</c> struct in server/events.go.
/// </summary>
internal sealed class InternalState
{
// ---- identity / sequencing ----
public Account? Account { get; set; }
public NatsClient? Client { get; set; }
public ulong Seq { get; set; }
public int Sid { get; set; }
// ---- remote server tracking ----
/// <summary>Map of server ID → serverUpdate. Mirrors Go <c>servers map[string]*serverUpdate</c>.</summary>
public Dictionary<string, ServerUpdate> Servers { get; set; } = new();
// ---- timers ----
/// <summary>Sweeper timer. Mirrors Go <c>sweeper *time.Timer</c>.</summary>
public System.Threading.Timer? Sweeper { get; set; }
/// <summary>Stats heartbeat timer. Mirrors Go <c>stmr *time.Timer</c>.</summary>
public System.Threading.Timer? StatsMsgTimer { get; set; }
// ---- reply handlers ----
/// <summary>
/// Pending reply subject → handler map.
/// Mirrors Go <c>replies map[string]msgHandler</c>.
/// </summary>
public Dictionary<string, Action<Subscription, NatsClient, Account, string, string, byte[], byte[]>> Replies { get; set; } = new();
// ---- queues ----
/// <summary>Outbound message send queue. Mirrors Go <c>sendq *ipQueue[*pubMsg]</c>.</summary>
public IpQueue<PubMsg>? SendQueue { get; set; }
/// <summary>Inbound receive queue. Mirrors Go <c>recvq *ipQueue[*inSysMsg]</c>.</summary>
public IpQueue<InSysMsg>? RecvQueue { get; set; }
/// <summary>Priority receive queue for STATSZ/Pings. Mirrors Go <c>recvqp *ipQueue[*inSysMsg]</c>.</summary>
public IpQueue<InSysMsg>? RecvQueuePriority { get; set; }
/// <summary>Reset channel used to restart the send loop. Mirrors Go <c>resetCh chan struct{}</c>.</summary>
public System.Threading.Channels.Channel<bool>? ResetChannel { get; set; }
// ---- durations ----
/// <summary>Maximum time before an orphaned server entry is removed. Mirrors Go <c>orphMax</c>.</summary>
public TimeSpan OrphanMax { get; set; }
/// <summary>Interval at which orphan checks run. Mirrors Go <c>chkOrph</c>.</summary>
public TimeSpan CheckOrphan { get; set; }
/// <summary>Interval between statsz publishes. Mirrors Go <c>statsz</c>.</summary>
public TimeSpan StatszInterval { get; set; }
/// <summary>Client-facing statsz interval. Mirrors Go <c>cstatsz</c>.</summary>
public TimeSpan ClientStatszInterval { get; set; }
// ---- misc ----
/// <summary>Short hash used for shared-inbox routing. Mirrors Go <c>shash string</c>.</summary>
public string ShortHash { get; set; } = string.Empty;
/// <summary>Inbox prefix for this server's internal client. Mirrors Go <c>inboxPre string</c>.</summary>
public string InboxPrefix { get; set; } = string.Empty;
/// <summary>Subscription for remote stats. Mirrors Go <c>remoteStatsSub *subscription</c>.</summary>
public Subscription? RemoteStatsSub { get; set; }
/// <summary>Time of the last statsz publish. Mirrors Go <c>lastStatsz time.Time</c>.</summary>
public DateTime LastStatsz { get; set; }
}
// ============================================================================
// ServerUpdate — remote server heartbeat tracking
// Mirrors Go <c>serverUpdate</c> struct in server/events.go.
// ============================================================================
/// <summary>
/// Tracks the sequence number and last-seen timestamp of a remote server's
/// system heartbeat. Used to detect orphaned servers.
/// Mirrors Go <c>serverUpdate</c> struct in server/events.go.
/// </summary>
internal sealed class ServerUpdate
{
/// <summary>Last sequence number received from the remote server.</summary>
public ulong Seq { get; set; }
/// <summary>Wall-clock time of the last heartbeat.</summary>
public DateTime LTime { get; set; }
}
// ============================================================================
// PubMsg — internally-queued outbound publish message
// Mirrors Go <c>pubMsg</c> struct in server/events.go.
// ============================================================================
/// <summary>
/// Holds an outbound message that the server wants to publish via the internal
/// send loop, avoiding direct route/gateway writes.
/// Mirrors Go <c>pubMsg</c> struct in server/events.go.
/// </summary>
internal sealed class PubMsg
{
public NatsClient? Client { get; set; }
public string Subject { get; set; } = string.Empty;
public string Reply { get; set; } = string.Empty;
public ServerInfo? Si { get; set; }
public byte[]? Hdr { get; set; }
public object? Msg { get; set; }
/// <summary>Compression type. TODO: session 12 — wire up compressionType enum.</summary>
public int Oct { get; set; }
public bool Echo { get; set; }
public bool Last { get; set; }
// TODO: session 12 — add pool return helper (returnToPool).
}
// ============================================================================
// DataStats — message/byte counter pair (sent or received)
// Mirrors Go <c>DataStats</c> struct in server/events.go.
// ============================================================================
/// <summary>
/// Reports how many messages and bytes were sent or received.
/// Optionally breaks out gateway, route, and leaf-node traffic.
/// Mirrors Go <c>DataStats</c> struct in server/events.go.
/// </summary>
public sealed class DataStats
{
[JsonPropertyName("msgs")]
public long Msgs { get; set; }
[JsonPropertyName("bytes")]
public long Bytes { get; set; }
[JsonPropertyName("gateways")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public MsgBytes? Gateways { get; set; }
[JsonPropertyName("routes")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public MsgBytes? Routes { get; set; }
[JsonPropertyName("leafs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public MsgBytes? Leafs { get; set; }
}
// ============================================================================
// MsgBytes — simple message+byte pair used inside DataStats
// Mirrors Go <c>MsgBytes</c> struct in server/events.go.
// ============================================================================
/// <summary>
/// A simple pair of message and byte counts, used as a nested breakdown
/// inside <see cref="DataStats"/>.
/// Mirrors Go <c>MsgBytes</c> struct in server/events.go.
/// </summary>
public sealed class MsgBytes
{
[JsonPropertyName("msgs")]
public long Msgs { get; set; }
[JsonPropertyName("bytes")]
public long Bytes { get; set; }
}
// ============================================================================
// RouteStat / GatewayStat — per-route and per-gateway stat snapshots
// Mirrors Go <c>RouteStat</c> and <c>GatewayStat</c> in server/events.go.
// ============================================================================
/// <summary>
/// Statistics snapshot for a single cluster route connection.
/// Mirrors Go <c>RouteStat</c> in server/events.go.
/// </summary>
public sealed class RouteStat
{
[JsonPropertyName("rid")]
public ulong Id { get; set; }
[JsonPropertyName("name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Name { get; set; }
[JsonPropertyName("sent")]
public DataStats Sent { get; set; } = new();
[JsonPropertyName("received")]
public DataStats Received { get; set; } = new();
[JsonPropertyName("pending")]
public int Pending { get; set; }
}
/// <summary>
/// Statistics snapshot for a gateway connection.
/// Mirrors Go <c>GatewayStat</c> in server/events.go.
/// </summary>
public sealed class GatewayStat
{
[JsonPropertyName("gwid")]
public ulong Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("sent")]
public DataStats Sent { get; set; } = new();
[JsonPropertyName("received")]
public DataStats Received { get; set; } = new();
[JsonPropertyName("inbound_connections")]
public int NumInbound { get; set; }
}
// ============================================================================
// ServerStatsMsg — periodic stats advisory published on $SYS.SERVER.{id}.STATSZ
// Mirrors Go <c>ServerStatsMsg</c> struct in server/events.go.
// ============================================================================
/// <summary>
/// Periodic advisory message containing the current server statistics.
/// Mirrors Go <c>ServerStatsMsg</c> struct in server/events.go.
/// </summary>
public sealed class ServerStatsMsg
{
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
[JsonPropertyName("statsz")]
public ServerStatsAdvisory Stats { get; set; } = new();
}
// ============================================================================
// ServerStatsAdvisory — the statsz payload inside ServerStatsMsg
// Mirrors Go <c>ServerStats</c> struct (advisory form) in server/events.go.
// NOTE: distinct from the internal ServerStats in NatsServerTypes.cs.
// ============================================================================
/// <summary>
/// The JSON-serialisable statistics payload included inside <see cref="ServerStatsMsg"/>.
/// Mirrors Go <c>ServerStats</c> struct (advisory form) in server/events.go.
/// </summary>
public sealed class ServerStatsAdvisory
{
[JsonPropertyName("start")]
public DateTime Start { get; set; }
[JsonPropertyName("mem")]
public long Mem { get; set; }
[JsonPropertyName("cores")]
public int Cores { get; set; }
[JsonPropertyName("cpu")]
public double Cpu { get; set; }
[JsonPropertyName("connections")]
public int Connections { get; set; }
[JsonPropertyName("total_connections")]
public ulong TotalConnections { get; set; }
[JsonPropertyName("active_accounts")]
public int ActiveAccounts { get; set; }
[JsonPropertyName("subscriptions")]
public uint NumSubs { get; set; }
[JsonPropertyName("sent")]
public DataStats Sent { get; set; } = new();
[JsonPropertyName("received")]
public DataStats Received { get; set; } = new();
[JsonPropertyName("slow_consumers")]
public long SlowConsumers { get; set; }
[JsonPropertyName("slow_consumer_stats")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public SlowConsumersStats? SlowConsumersStats { get; set; }
[JsonPropertyName("stale_connections")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public long StaleConnections { get; set; }
[JsonPropertyName("stale_connection_stats")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public StaleConnectionStats? StaleConnectionStats { get; set; }
[JsonPropertyName("stalled_clients")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public long StalledClients { get; set; }
[JsonPropertyName("routes")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<RouteStat>? Routes { get; set; }
[JsonPropertyName("gateways")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<GatewayStat>? Gateways { get; set; }
[JsonPropertyName("active_servers")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int ActiveServers { get; set; }
/// <summary>JetStream stats. TODO: session 19 — wire JetStreamVarz type.</summary>
[JsonPropertyName("jetstream")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? JetStream { get; set; }
[JsonPropertyName("gomemlimit")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public long MemLimit { get; set; }
[JsonPropertyName("gomaxprocs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int MaxProcs { get; set; }
}
// ============================================================================
// SlowConsumersStats / StaleConnectionStats — advisory-layer per-kind counters
// These are the JSON-serialisable variants used in ServerStatsAdvisory.
// The internal atomic counters live in NatsServerTypes.cs (SlowConsumerStats /
// StaleConnectionStats with different casing).
// ============================================================================
/// <summary>
/// Per-kind slow-consumer counters included in stats advisories.
/// Mirrors Go <c>SlowConsumersStats</c> in server/monitor.go.
/// </summary>
public sealed class SlowConsumersStats
{
[JsonPropertyName("clients")]
public ulong Clients { get; set; }
[JsonPropertyName("routes")]
public ulong Routes { get; set; }
[JsonPropertyName("gateways")]
public ulong Gateways { get; set; }
[JsonPropertyName("leafs")]
public ulong Leafs { get; set; }
}
/// <summary>
/// Per-kind stale-connection counters included in stats advisories.
/// Mirrors Go <c>StaleConnectionStats</c> in server/monitor.go.
/// </summary>
public sealed class StaleConnectionStats
{
[JsonPropertyName("clients")]
public ulong Clients { get; set; }
[JsonPropertyName("routes")]
public ulong Routes { get; set; }
[JsonPropertyName("gateways")]
public ulong Gateways { get; set; }
[JsonPropertyName("leafs")]
public ulong Leafs { get; set; }
}
// ============================================================================
// ConnectEventMsg / DisconnectEventMsg — client lifecycle advisories
// Mirrors Go structs in server/events.go.
// ============================================================================
/// <summary>
/// Advisory published on <c>$SYS.ACCOUNT.{acc}.CONNECT</c> when a new
/// client connection is established within a tracked account.
/// Mirrors Go <c>ConnectEventMsg</c> in server/events.go.
/// </summary>
public sealed class ConnectEventMsg : TypedEvent
{
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
[JsonPropertyName("client")]
public ClientInfo Client { get; set; } = new();
}
/// <summary>
/// Advisory published on <c>$SYS.ACCOUNT.{acc}.DISCONNECT</c> when a
/// previously-tracked client connection closes.
/// Mirrors Go <c>DisconnectEventMsg</c> in server/events.go.
/// </summary>
public sealed class DisconnectEventMsg : TypedEvent
{
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
[JsonPropertyName("client")]
public ClientInfo Client { get; set; } = new();
[JsonPropertyName("sent")]
public DataStats Sent { get; set; } = new();
[JsonPropertyName("received")]
public DataStats Received { get; set; } = new();
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
// ============================================================================
// OCSPPeerRejectEventMsg / OCSPPeerChainlinkInvalidEventMsg
// Mirrors Go structs in server/events.go.
// ============================================================================
/// <summary>
/// Advisory published when a peer TLS handshake is rejected due to OCSP
/// invalidation of the peer's leaf certificate.
/// Mirrors Go <c>OCSPPeerRejectEventMsg</c> in server/events.go.
/// </summary>
public sealed class OcspPeerRejectEventMsg : TypedEvent
{
[JsonPropertyName("kind")]
public string Kind { get; set; } = string.Empty;
[JsonPropertyName("peer")]
public CertInfo Peer { get; set; } = new();
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// Advisory published when a certificate in a valid TLS chain is found to be
/// OCSP-invalid during a peer handshake. Both the invalid link and the
/// peer's leaf cert are included.
/// Mirrors Go <c>OCSPPeerChainlinkInvalidEventMsg</c> in server/events.go.
/// </summary>
public sealed class OcspPeerChainlinkInvalidEventMsg : TypedEvent
{
[JsonPropertyName("link")]
public CertInfo Link { get; set; } = new();
[JsonPropertyName("peer")]
public CertInfo Peer { get; set; } = new();
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
// ============================================================================
// AccountNumConns / AccountStat — account connection count advisories
// Mirrors Go structs in server/events.go.
// ============================================================================
/// <summary>
/// Advisory heartbeat published when the connection count for a tracked
/// account changes, or on a periodic schedule.
/// Mirrors Go <c>AccountNumConns</c> struct in server/events.go.
/// </summary>
public sealed class AccountNumConns : TypedEvent
{
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
// Embedded AccountStat fields are inlined via composition.
[JsonPropertyName("acc")]
public string Account { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("conns")]
public int Conns { get; set; }
[JsonPropertyName("leafnodes")]
public int LeafNodes { get; set; }
[JsonPropertyName("total_conns")]
public int TotalConns { get; set; }
[JsonPropertyName("num_subscriptions")]
public uint NumSubs { get; set; }
[JsonPropertyName("sent")]
public DataStats Sent { get; set; } = new();
[JsonPropertyName("received")]
public DataStats Received { get; set; } = new();
[JsonPropertyName("slow_consumers")]
public long SlowConsumers { get; set; }
}
/// <summary>
/// Statistic data common to <see cref="AccountNumConns"/> and account-level
/// monitoring responses.
/// Mirrors Go <c>AccountStat</c> struct in server/events.go.
/// </summary>
public sealed class AccountStat
{
[JsonPropertyName("acc")]
public string Account { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("conns")]
public int Conns { get; set; }
[JsonPropertyName("leafnodes")]
public int LeafNodes { get; set; }
[JsonPropertyName("total_conns")]
public int TotalConns { get; set; }
[JsonPropertyName("num_subscriptions")]
public uint NumSubs { get; set; }
[JsonPropertyName("sent")]
public DataStats Sent { get; set; } = new();
[JsonPropertyName("received")]
public DataStats Received { get; set; } = new();
[JsonPropertyName("slow_consumers")]
public long SlowConsumers { get; set; }
}
/// <summary>
/// Internal request payload sent when this server first starts tracking an
/// account, asking peer servers for their local connection counts.
/// Mirrors Go <c>accNumConnsReq</c> struct in server/events.go.
/// </summary>
internal sealed class AccNumConnsReq
{
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
[JsonPropertyName("acc")]
public string Account { get; set; } = string.Empty;
}
// ============================================================================
// ServerCapability / ServerID — server identity and capability flags
// Mirrors Go types in server/events.go.
// ============================================================================
/// <summary>
/// Bit-flag capability set for a remote server.
/// Mirrors Go <c>ServerCapability uint64</c> in server/events.go.
/// </summary>
[Flags]
public enum ServerCapability : ulong
{
/// <summary>No capabilities.</summary>
None = 0,
/// <summary>Server has JetStream enabled. Mirrors Go <c>JetStreamEnabled</c>.</summary>
JetStreamEnabled = 1UL << 0,
/// <summary>New stream snapshot capability. Mirrors Go <c>BinaryStreamSnapshot</c>.</summary>
BinaryStreamSnapshot = 1UL << 1,
/// <summary>Move NRG traffic out of system account. Mirrors Go <c>AccountNRG</c>.</summary>
AccountNrg = 1UL << 2,
}
/// <summary>
/// Minimal static identity for a remote server (name, host, ID).
/// Mirrors Go <c>ServerID</c> struct in server/events.go.
/// </summary>
public sealed class ServerIdentity
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("host")]
public string Host { get; set; } = string.Empty;
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,384 @@
// Copyright 2018-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/gateway.go in the NATS server Go source.
using System.Threading;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// Session 16: Gateways
// ============================================================================
/// <summary>
/// Represents the interest mode for a given account on a gateway connection.
/// Mirrors Go <c>GatewayInterestMode</c> byte iota in gateway.go.
/// Do not change values — they are part of the wire-level gossip protocol.
/// </summary>
public enum GatewayInterestMode : byte
{
/// <summary>
/// Default mode: the cluster sends to a gateway unless told there is no
/// interest (applies to plain subscribers only).
/// </summary>
Optimistic = 0,
/// <summary>
/// Transitioning: the gateway has been sending too many no-interest signals
/// and is switching to <see cref="InterestOnly"/> mode for this account.
/// </summary>
Transitioning = 1,
/// <summary>
/// Interest-only mode: the cluster has sent all its subscription interest;
/// the gateway only forwards messages when explicit interest is known.
/// </summary>
InterestOnly = 2,
/// <summary>
/// Internal sentinel used after a cache flush; not part of the public wire enum.
/// </summary>
CacheFlushed = 3,
}
/// <summary>
/// Server-level gateway state kept on the <see cref="NatsServer"/> instance.
/// Replaces the stub that was in <c>NatsServerTypes.cs</c>.
/// Mirrors Go <c>srvGateway</c> struct in gateway.go.
/// </summary>
internal sealed class SrvGateway
{
/// <summary>
/// Total number of queue subs across all remote gateways.
/// Accessed via <c>Interlocked</c> — must be 64-bit aligned.
/// </summary>
public long TotalQSubs;
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion);
/// <summary>
/// True if both a gateway name and port are configured (immutable after init).
/// </summary>
public bool Enabled { get; set; }
/// <summary>Name of this server's gateway cluster.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Outbound gateway connections keyed by remote gateway name.</summary>
public Dictionary<string, ClientConnection> Out { get; set; } = new();
/// <summary>
/// Outbound gateway connections in RTT order, used for message routing.
/// </summary>
public List<ClientConnection> Outo { get; set; } = [];
/// <summary>Inbound gateway connections keyed by connection ID.</summary>
public Dictionary<ulong, ClientConnection> In { get; set; } = new();
/// <summary>Per-remote-gateway configuration, keyed by gateway name.</summary>
public Dictionary<string, GatewayCfg> Remotes { get; set; } = new();
/// <summary>Reference-counted set of all gateway URLs in the cluster.</summary>
public RefCountedUrlSet Urls { get; set; } = new();
/// <summary>This server's own gateway URL (after random-port resolution).</summary>
public string Url { get; set; } = string.Empty;
/// <summary>Gateway INFO protocol object.</summary>
public ServerInfo? Info { get; set; }
/// <summary>Pre-marshalled INFO JSON bytes.</summary>
public byte[]? InfoJson { get; set; }
/// <summary>When true, reject connections from gateways not in <see cref="Remotes"/>.</summary>
public bool RejectUnknown { get; set; }
/// <summary>
/// Reply prefix bytes: <c>"$GNR.&lt;reserved&gt;.&lt;clusterHash&gt;.&lt;serverHash&gt;."</c>
/// </summary>
public byte[] ReplyPfx { get; set; } = [];
// Backward-compatibility reply prefix and hash (old "$GR." scheme)
public byte[] OldReplyPfx { get; set; } = [];
public byte[] OldHash { get; set; } = [];
// -------------------------------------------------------------------------
// pasi — per-account subject interest tally (protected by its own mutex)
// -------------------------------------------------------------------------
/// <summary>
/// Per-account subject-interest tally.
/// Outer key = account name; inner key = subject (or "subject queue" pair);
/// value = tally struct.
/// Mirrors Go's anonymous <c>pasi</c> embedded struct in <c>srvGateway</c>.
/// </summary>
private readonly Lock _pasiLock = new();
public Dictionary<string, Dictionary<string, SitAlly>> Pasi { get; set; } = new();
public Lock PasiLock => _pasiLock;
// -------------------------------------------------------------------------
// Recent subscription tracking (thread-safe map)
// -------------------------------------------------------------------------
/// <summary>
/// Recent subscriptions for a given account (subject → expiry ticks).
/// Mirrors Go's <c>rsubs sync.Map</c>.
/// </summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, long> RSubs { get; set; } = new();
// -------------------------------------------------------------------------
// Other server-level gateway fields
// -------------------------------------------------------------------------
/// <summary>DNS resolver used before dialling gateway connections.</summary>
public INetResolver? Resolver { get; set; }
/// <summary>Max buffer size for sending queue-sub protocol (used in tests).</summary>
public int SqbSz { get; set; }
/// <summary>How long to look for a subscription match for a reply message.</summary>
public TimeSpan RecSubExp { get; set; }
/// <summary>Server ID hash (6 bytes) for routing mapped replies.</summary>
public byte[] SIdHash { get; set; } = [];
/// <summary>
/// Map from a route server's hashed ID (6 bytes) to the route client.
/// Mirrors Go's <c>routesIDByHash sync.Map</c>.
/// </summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, ClientConnection> RoutesIdByHash { get; set; } = new();
/// <summary>
/// Gateway URLs from this server's own entry in the Gateways config block,
/// used for monitoring reports.
/// </summary>
public List<string> OwnCfgUrls { get; set; } = [];
// -------------------------------------------------------------------------
// Lock helpers
// -------------------------------------------------------------------------
public void AcquireReadLock() => _lock.EnterReadLock();
public void ReleaseReadLock() => _lock.ExitReadLock();
public void AcquireWriteLock() => _lock.EnterWriteLock();
public void ReleaseWriteLock() => _lock.ExitWriteLock();
}
/// <summary>
/// Subject-interest tally entry. Indicates whether the key in the map is a
/// queue subscription and how many matching subscriptions exist.
/// Mirrors Go <c>sitally</c> struct in gateway.go.
/// </summary>
internal sealed class SitAlly
{
/// <summary>Number of subscriptions directly matching the subject/queue key.</summary>
public int N { get; set; }
/// <summary>True if this entry represents a queue subscription.</summary>
public bool Q { get; set; }
}
/// <summary>
/// Runtime configuration for a single remote gateway.
/// Wraps <see cref="RemoteGatewayOpts"/> with connection-attempt state and a lock.
/// Mirrors Go <c>gatewayCfg</c> struct in gateway.go.
/// </summary>
internal sealed class GatewayCfg
{
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion);
/// <summary>The raw remote-gateway options this cfg was built from.</summary>
public RemoteGatewayOpts? RemoteOpts { get; set; }
/// <summary>6-byte cluster hash used for reply routing.</summary>
public byte[] Hash { get; set; } = [];
/// <summary>4-byte old-style hash for backward compatibility.</summary>
public byte[] OldHash { get; set; } = [];
/// <summary>Map of URL string → parsed URL for this remote gateway.</summary>
public Dictionary<string, Uri> Urls { get; set; } = new();
/// <summary>Number of connection attempts made so far.</summary>
public int ConnAttempts { get; set; }
/// <summary>TLS server name override for SNI.</summary>
public string TlsName { get; set; } = string.Empty;
/// <summary>True if this remote was discovered via gossip (not configured).</summary>
public bool Implicit { get; set; }
/// <summary>When true, monitoring should refresh the URL list on next varz inspection.</summary>
public bool VarzUpdateUrls { get; set; }
// Forwarded properties from RemoteGatewayOpts
public string Name { get => RemoteOpts?.Name ?? string.Empty; }
// -------------------------------------------------------------------------
// Lock helpers
// -------------------------------------------------------------------------
public void AcquireReadLock() => _lock.EnterReadLock();
public void ReleaseReadLock() => _lock.ExitReadLock();
public void AcquireWriteLock() => _lock.EnterWriteLock();
public void ReleaseWriteLock() => _lock.ExitWriteLock();
}
/// <summary>
/// Per-connection gateway state embedded in <see cref="ClientConnection"/>
/// when the connection kind is <c>Gateway</c>.
/// Mirrors Go <c>gateway</c> struct in gateway.go.
/// </summary>
internal sealed class Gateway
{
/// <summary>Name of the remote gateway cluster.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Configuration block for the remote gateway.</summary>
public GatewayCfg? Cfg { get; set; }
/// <summary>URL used for CONNECT after receiving the remote INFO (outbound only).</summary>
public Uri? ConnectUrl { get; set; }
/// <summary>
/// Per-account subject interest (outbound connection).
/// Maps account name → <see cref="OutSide"/> for that account.
/// Uses a thread-safe map because it is read from multiple goroutines.
/// </summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, OutSide>? OutSim { get; set; }
/// <summary>
/// Per-account no-interest subjects or interest-only mode (inbound connection).
/// </summary>
public Dictionary<string, InSide>? InSim { get; set; }
/// <summary>True if this is an outbound gateway connection.</summary>
public bool Outbound { get; set; }
/// <summary>
/// Set in the read loop without locking to record that the inbound side
/// sent its CONNECT protocol.
/// </summary>
public bool Connected { get; set; }
/// <summary>
/// True if the remote server only understands the old <c>$GR.</c> prefix,
/// not the newer <c>$GNR.</c> scheme.
/// </summary>
public bool UseOldPrefix { get; set; }
/// <summary>
/// When true the inbound side switches accounts to interest-only mode
/// immediately, so the outbound side can disregard optimistic mode.
/// </summary>
public bool InterestOnlyMode { get; set; }
/// <summary>Name of the remote server on this gateway connection.</summary>
public string RemoteName { get; set; } = string.Empty;
}
/// <summary>
/// Outbound subject-interest entry for a single account on a gateway connection.
/// Mirrors Go <c>outsie</c> struct in gateway.go.
/// </summary>
internal sealed class OutSide
{
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion);
/// <summary>Current interest mode for this account on the outbound gateway.</summary>
public GatewayInterestMode Mode { get; set; }
/// <summary>
/// Set of subjects for which the remote has signalled no-interest.
/// Null when the remote has sent all its subscriptions (interest-only mode).
/// </summary>
public HashSet<string>? Ni { get; set; }
/// <summary>
/// Subscription index: contains queue subs in optimistic mode,
/// or all subs when <see cref="Mode"/> has been switched.
/// </summary>
public SubscriptionIndex? Sl { get; set; }
/// <summary>Number of queue subscriptions tracked in <see cref="Sl"/>.</summary>
public int Qsubs { get; set; }
// -------------------------------------------------------------------------
// Lock helpers
// -------------------------------------------------------------------------
public void AcquireReadLock() => _lock.EnterReadLock();
public void ReleaseReadLock() => _lock.ExitReadLock();
public void AcquireWriteLock() => _lock.EnterWriteLock();
public void ReleaseWriteLock() => _lock.ExitWriteLock();
}
/// <summary>
/// Inbound subject-interest entry for a single account on a gateway connection.
/// Tracks subjects for which an RS- was sent to the remote, and the current mode.
/// Mirrors Go <c>insie</c> struct in gateway.go.
/// </summary>
internal sealed class InSide
{
/// <summary>
/// Subjects for which RS- was sent to the remote (null when in interest-only mode).
/// </summary>
public HashSet<string>? Ni { get; set; }
/// <summary>Current interest mode for this account on the inbound gateway.</summary>
public GatewayInterestMode Mode { get; set; }
}
/// <summary>
/// A single gateway reply-mapping entry: the mapped subject and its expiry.
/// Mirrors Go <c>gwReplyMap</c> struct in gateway.go.
/// </summary>
internal sealed class GwReplyMap
{
/// <summary>The mapped (routed) subject string.</summary>
public string Ms { get; set; } = string.Empty;
/// <summary>Expiry expressed as <see cref="DateTime.Ticks"/> (UTC).</summary>
public long Exp { get; set; }
}
/// <summary>
/// Gateway reply routing table and a fast-path check flag.
/// Mirrors Go <c>gwReplyMapping</c> struct in gateway.go.
/// </summary>
internal sealed class GwReplyMapping
{
/// <summary>
/// Non-zero when the mapping table should be consulted while processing
/// inbound messages. Accessed via <c>Interlocked</c> — must be 32-bit aligned.
/// </summary>
public int Check;
/// <summary>Active reply-subject → GwReplyMap entries.</summary>
public Dictionary<string, GwReplyMap> Mapping { get; set; } = new();
/// <summary>
/// Returns the routed subject for <paramref name="subject"/> if a mapping
/// exists, otherwise returns the original subject and <c>false</c>.
/// Caller must hold any required lock before invoking.
/// </summary>
public (byte[] Subject, bool Found) Get(byte[] subject)
{
// TODO: session 16 — implement mapping lookup
return (subject, false);
}
}

View File

@@ -0,0 +1,112 @@
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>
/// Explicit init hook for Go parity.
/// Mirrors package <c>init()</c> in server/ats/ats.go.
/// This method is intentionally idempotent.
/// </summary>
public static void Init()
{
// Ensure a non-zero cached timestamp is present.
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
Interlocked.CompareExchange(ref _utime, now, 0);
}
/// <summary>
/// Registers a user. Starts the background timer when the first registrant calls this.
/// Each call to <see cref="Register"/> must be paired with a call to <see cref="Unregister"/>.
/// </summary>
public static void Register()
{
var v = Interlocked.Increment(ref _refs);
if (v == 1)
{
Interlocked.Exchange(ref _utime, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L);
lock (_lock)
{
_timer?.Dispose();
_timer = new Timer(_ =>
{
Interlocked.Exchange(ref _utime, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L);
}, null, TickInterval, TickInterval);
}
}
}
/// <summary>
/// Unregisters a user. Stops the background timer when the last registrant calls this.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when unregister is called more times than register.</exception>
public static void Unregister()
{
var v = Interlocked.Decrement(ref _refs);
if (v == 0)
{
lock (_lock)
{
_timer?.Dispose();
_timer = null;
}
}
else if (v < 0)
{
Interlocked.Exchange(ref _refs, 0);
throw new InvalidOperationException("ats: unbalanced unregister for access time state");
}
}
/// <summary>
/// Returns the last cached Unix nanosecond timestamp.
/// If no registrant is active, returns a fresh timestamp (avoids returning zero).
/// </summary>
public static long AccessTime()
{
var v = Interlocked.Read(ref _utime);
if (v == 0)
{
v = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L;
Interlocked.CompareExchange(ref _utime, v, 0);
v = Interlocked.Read(ref _utime);
}
return v;
}
/// <summary>
/// Resets all state. For testing only.
/// </summary>
internal static void Reset()
{
lock (_lock)
{
_timer?.Dispose();
_timer = null;
}
Interlocked.Exchange(ref _refs, 0);
Interlocked.Exchange(ref _utime, 0);
}
}

View File

@@ -0,0 +1,118 @@
// Copyright 2018-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/ring.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Internal;
// -------------------------------------------------------------------------
// Placeholder types — will be populated in session 08 (Client) and
// session 12 (Monitor/Events).
// -------------------------------------------------------------------------
/// <summary>
/// Subset of client connection info exposed on the monitoring endpoint (/connz).
/// Full implementation is in session 12 (monitor.go).
/// </summary>
public class ConnInfo { }
/// <summary>
/// Subscription detail for the monitoring endpoint.
/// Full implementation is in session 12 (monitor.go).
/// </summary>
public class SubDetail { }
// -------------------------------------------------------------------------
// ClosedClient / ClosedRingBuffer (ring.go)
// -------------------------------------------------------------------------
/// <summary>
/// Wraps connection info with optional items for the /connz endpoint.
/// Mirrors <c>closedClient</c> in server/ring.go.
/// </summary>
public sealed class ClosedClient
{
public ConnInfo Info { get; init; } = new();
public IReadOnlyList<SubDetail> Subs { get; init; } = [];
public string User { get; init; } = string.Empty;
public string Account { get; init; } = string.Empty;
}
/// <summary>
/// Fixed-size ring buffer that retains the most recent closed connections,
/// evicting the oldest when full.
/// Mirrors <c>closedRingBuffer</c> in server/ring.go.
/// </summary>
public sealed class ClosedRingBuffer
{
private ulong _total;
private readonly ClosedClient?[] _conns;
/// <summary>Creates a ring buffer that holds at most <paramref name="max"/> entries.</summary>
public ClosedRingBuffer(int max)
{
_conns = new ClosedClient?[max];
}
/// <summary>
/// Appends a closed connection, evicting the oldest if the buffer is full.
/// Mirrors <c>closedRingBuffer.append</c>.
/// </summary>
public void Append(ClosedClient cc)
{
_conns[Next()] = cc;
_total++;
}
// Index of the slot to write next — wraps around.
private int Next() => (int)(_total % (ulong)_conns.Length);
/// <summary>
/// Returns the number of entries currently stored (≤ capacity).
/// Mirrors <c>closedRingBuffer.len</c>.
/// </summary>
public int Len() =>
_total > (ulong)_conns.Length ? _conns.Length : (int)_total;
/// <summary>
/// Returns the total number of connections ever appended (not capped).
/// Mirrors <c>closedRingBuffer.totalConns</c>.
/// </summary>
public ulong TotalConns() => _total;
/// <summary>
/// Returns a chronologically ordered copy of the stored closed connections.
/// The caller may freely modify the returned array.
/// Mirrors <c>closedRingBuffer.closedClients</c>.
/// </summary>
public ClosedClient?[] ClosedClients()
{
var len = Len();
var dup = new ClosedClient?[len];
var head = Next();
if (_total <= (ulong)_conns.Length || head == 0)
{
Array.Copy(_conns, dup, len);
}
else
{
var fp = _conns.AsSpan(head); // oldest … end of array
var sp = _conns.AsSpan(0, head); // wrap-around … newest
fp.CopyTo(dup.AsSpan());
sp.CopyTo(dup.AsSpan(fp.Length));
}
return dup;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,136 @@
// Copyright 2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/sdm.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Internal.DataStructures;
/// <summary>
/// Per-sequence data tracked by <see cref="StreamDeletionMeta"/>.
/// Mirrors <c>SDMBySeq</c> in server/sdm.go.
/// </summary>
public readonly struct SdmBySeq
{
/// <summary>Whether this sequence was the last message for its subject.</summary>
public bool Last { get; init; }
/// <summary>Timestamp (nanoseconds UTC) when the removal/SDM was last proposed.</summary>
public long Ts { get; init; }
}
/// <summary>
/// Tracks pending subject delete markers (SDMs) and message removals for a stream.
/// Used by JetStream cluster consensus to avoid redundant proposals.
/// Mirrors <c>SDMMeta</c> in server/sdm.go.
/// </summary>
public sealed class StreamDeletionMeta
{
// Per-subject pending-count totals.
private readonly Dictionary<string, ulong> _totals = new(1);
// Per-sequence data keyed by sequence number.
private readonly Dictionary<ulong, SdmBySeq> _pending = new(1);
// -------------------------------------------------------------------------
// Header constants (forward-declared; populated in session 19 — JetStream).
// isSubjectDeleteMarker checks these header keys.
// -------------------------------------------------------------------------
// Mirrors JSMarkerReason header key (defined in jetstream.go).
internal const string HeaderJsMarkerReason = "Nats-Marker-Reason";
// Mirrors KVOperation header key (defined in jetstream.go).
internal const string HeaderKvOperation = "KV-Operation";
// Mirrors KVOperationValuePurge (defined in jetstream.go).
internal const string KvOperationValuePurge = "PURGE";
/// <summary>
/// Returns true when the given header block contains a subject delete marker
/// (either a JetStream marker or a KV purge operation).
/// Mirrors <c>isSubjectDeleteMarker</c> in server/sdm.go.
/// </summary>
public static bool IsSubjectDeleteMarker(ReadOnlySpan<byte> hdr)
{
// Simplified header scan: checks whether JSMarkerReason key is present
// or whether KV-Operation equals "PURGE".
// Full implementation depends on SliceHeader from session 08 (client.go).
// Until then this provides the correct contract.
var text = System.Text.Encoding.UTF8.GetString(hdr);
if (text.Contains(HeaderJsMarkerReason))
return true;
if (text.Contains($"{HeaderKvOperation}: {KvOperationValuePurge}"))
return true;
return false;
}
/// <summary>
/// Tries to get the pending entry for <paramref name="seq"/>.
/// </summary>
public bool TryGetPending(ulong seq, out SdmBySeq entry) => _pending.TryGetValue(seq, out entry);
/// <summary>
/// Sets the pending entry for <paramref name="seq"/>.
/// </summary>
public void SetPending(ulong seq, SdmBySeq entry) => _pending[seq] = entry;
/// <summary>
/// Returns the pending count for <paramref name="subj"/>, or 0 if not tracked.
/// </summary>
public ulong GetSubjectTotal(string subj) => _totals.TryGetValue(subj, out var cnt) ? cnt : 0;
/// <summary>
/// Clears all tracked data.
/// Mirrors <c>SDMMeta.empty</c>.
/// </summary>
public void Empty()
{
_totals.Clear();
_pending.Clear();
}
/// <summary>
/// Tracks <paramref name="seq"/> as pending and returns whether it was
/// the last message for its subject. If the sequence is already tracked
/// the existing <c>Last</c> value is returned without modification.
/// Mirrors <c>SDMMeta.trackPending</c>.
/// </summary>
public bool TrackPending(ulong seq, string subj, bool last)
{
if (_pending.TryGetValue(seq, out var p))
return p.Last;
_pending[seq] = new SdmBySeq { Last = last, Ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L };
_totals[subj] = _totals.TryGetValue(subj, out var cnt) ? cnt + 1 : 1;
return last;
}
/// <summary>
/// Removes <paramref name="seq"/> and decrements the pending count for
/// <paramref name="subj"/>, deleting the subject entry when it reaches zero.
/// Mirrors <c>SDMMeta.removeSeqAndSubject</c>.
/// </summary>
public void RemoveSeqAndSubject(ulong seq, string subj)
{
if (!_pending.Remove(seq))
return;
if (_totals.TryGetValue(subj, out var msgs))
{
if (msgs <= 1)
_totals.Remove(subj);
else
_totals[subj] = msgs - 1;
}
}
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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