Files
UA-.NETStandard/docs/FileSystemClient.md
Marc Schier fa8d45f2cf Fix server/core validation, lifecycle and protocol regressions (#4477)
# Description

**Server/core audit fixes with regression tests. Latest review-fix
validation, merge validation and earlier acceptance results are
distinguished below.**

This addresses a 95-finding server/core report. Every finding was
reviewed with
the OPC UA specification knowledge base or, where the standard does not
prescribe a C# implementation, the corresponding ownership/configuration
contract. Incorrect or withdrawn subclaims remain qualified rather than
being
presented as additional fixes.

The original checkpoint covers session identity/certificate validation
and
revocation; certificate/key ownership and transactional updates; TCP
reconnect,
receive/admission and buffer lifetime; HTTP quotas; subscription
admission,
durability and pooled payloads; file-operation lifecycle; historian
aggregates;
fluent/state-machine behavior; and related utilities. It includes
targeted
regressions and directly related documentation.

The approved acceptance cleanup is separate in the commit history:

| Cleanup | Verified result |
| --- | --- |
| Invalid identity-policy and KeyCredential fixture inputs | Fixtures
explicitly advertise Username and use its generated policy ID.
Credential persistence uses valid clear mode inside the existing
encrypted admin context. Original permission, lockout and negative
encryption assertions remain. |
| Native GDS lifetime and concurrent connection failures | Retain
ApplicationInstance until client teardown. A deterministic native
reproducer proved the test-only channel cap of 10 was exhausted;
removing that override restores the existing bounded default of 1000. No
global serialization or production quota change. |
| Stress certificate ownership and close accounting | Balance two helper
references and the fake transport's server-certificate ownership.
Replace the invalid event-count bound with exactly-once physical
disposal, opens = closes, zero active entries and one logical entry
lifetime. |
| Participant deadlines | Use the injected TimeProvider with the
existing cross-framework WaitAsync overload. Tests pin one tick before,
exact expiry, terminal notification, recovery and timely completion. |
| Reproducibility and platform assertions | Preserve canonical WoT
bytes/hashes, explicit display-culture contracts and exact
numeric-vector assertions. Accept Apple's CryptographicException subtype
without weakening disposal balance. |
| Missing user-signature proof | Reject null/empty proofs before OS
crypto. Return BadUserSignatureInvalid per Part 4, 5.7.3.3. None-policy
and valid-signature controls remain. A CI-only test nullable-context
error was corrected without changing production behavior. |
| Durable sample restore and shared test storage | Dequeue waits for
actual batch residency instead of treating failed restore completion as
success, and restored files close before deletion. Five deterministic
regressions failed before the fix. The two fixtures that clear the
shared batch directory are isolated; all original 3,000-item assertions
remain unchanged. |

## Latest review follow-up

Commit `828856850219518970e8e2f580b8f94b3c1fc748` fixes the deferred
robotics
disposal reported in the latest review.
`RobotIntentNodeManager.DisposeAsync`
now joins a shared controller-host drain and then explicitly awaits base
node-manager disposal. A controller's bounded shutdown wait may defer
its
cleanup, but no longer lets the node-manager override finish early.
Concurrent
callers, including an asynchronous call after synchronous `Dispose`,
cannot
bypass the drain after the host list has been detached.

Synchronous disposal remains nonblocking for deferred work. Deferred
initialization failures are logged and retained, and base cleanup
failures
propagate to asynchronous callers. The existing controller-level bounded
disposal contract is unchanged; the node-manager contract is documented
in
`docs/Robotics.md`.

| Regression requirement | Evidence |
| --- | --- |
| Wait for deferred hosts; preserve synchronous compatibility and
concurrent joins | `NodeManagerAsyncDisposalWaitsForDeferredHostsAsync`:
four cases covering both entry-point orders and zero/positive host
shutdown waits. All four failed before the fix. |
| Wait through base cleanup and observe failures |
`NodeManagerDisposalWaitsForBaseCleanupAndReportsItsFailureAsync`: four
cases hold the actual base-resource semaphore, verify pending callers
after the host finishes, then check success or the same propagated
exception and exactly-once cleanup. |

The focused `IntentHostingCoverageTests` and
`IntentControllerHostTests.Dispose`
selection passed **30/30 on net10.0 and 30/30 on net48**, with no
failures or
skips. The final net10 solution build passed with zero errors and only
the four
previously documented CA2249 warnings in unchanged fuzz-test code. No
new
warnings, analyzer suppressions, dependencies, or weakened assertions
were added.
The review thread received a commit-specific reply and was explicitly
resolved.

This verification is scoped to the robotics feedback; the earlier full
Subscriptions-run limitation remains recorded in the merge section
below.


## Documentation review follow-up (2026-09-14)

Commit `f9a9686fcc5d3cfd19cbbc8c2d826e20f5c7e72f` addresses the six
follow-up
threads on documentation and implementation rationale. Each has an
in-thread
reply citing the commit and an explicit resolution.

Multiline XML documentation now covers the declarations introduced or
modified
throughout the entire PR, including internal contracts, samples, tests,
and
public members of related private helpers. A syntax-aware inventory
checked
**2,031 declarations across all 256 PR C# paths with zero documentation
gaps**.
A supplementary check also covers **75 previously unchanged public
members**
of private helpers involved in the changed code. Token comparison across
the
conditional-compilation branches proves this follow-up changes **no
executable
C# or test assertions**. Project-file changes are rationale comments
only.

The implementation questions are documented at their source:

| Question | Resolution |
| --- | --- |
| Certificate assembly friendship | Server Push configuration reuses the
internal explicit-subject CSR helper with the existing key; no public
crypto API was added. |
| Core.Types assembly friendship | Server builders use internal
transition callbacks/revision guards; Server.Tests checks the revision
deterministically without creating a public compatibility contract. |
| Always-single-reader notifications | One dequeuer preserves event
order; optional concurrency fans out within one event and joins before
the next event. |
| Asynchronous disposal completion | `DisposeAsync` already joins the
completion published after admitted operations and all cleanup attempts.
The compatibility `Dispose` remains nonblocking to avoid deadlocking an
admitted callback against its own drain. |

The requested detailed review disposition follows. It distinguishes
actual
fixes from confirmed policies and incorrect subclaims rather than
counting every
comment as a new runtime change.


### Detailed disposition of review 5191795284

The [SDK
review](https://github.com/OPCFoundation/UA-.NETStandard/pull/4477#pullrequestreview-5191795284)
was submitted against `1c4a9df6c`, before the subsequent merge and
feedback fixes.
Its **15 inline threads** have each received an action-specific reply
and an
explicit resolution. A fresh GraphQL query verified those replies and
resolution
states; the fixes in `b8a59990f` remain ancestors of the current branch.

| Review concern | Action and disposition | Concrete regression evidence
|
| --- | --- | --- |
| Injected certificate-store providers bypassed | **Fixed.**
Registration, configuration snapshots, and validation use the injected
providers. Explicit custom types remain authoritative; the built-in
Directory catch-all is deferred so native X509 paths and later custom
providers are not captured. Failed store opens dispose the created
store. | `InjectedProviderResolvesRegisteredAndConfiguredTrustStores`
covers default-provider ordering and both registration paths; provider
validation/open-failure cases and the unchanged
`TrustedCertificateInjectedViaIndependentX509StoreIsObservedAsync` cover
real use and independent-store visibility. |
| Unguarded timer-thread leadership callbacks | **Fixed.** Each
subscriber's exception is logged independently and cannot suppress
subsequent subscribers or fail lease operations. `IsLeader` reads the
deadline without invoking application callbacks. |
`ThrowingLeadershipSubscriberDoesNotFailAcquireOrDisposeAsync`,
`ExpiryNotifiesOtherSubscribersWhenOneThrowsAsync`, and
`IsLeaderDoesNotDispatchExpiryNotificationsAsync` were all demonstrated
failing before the fix. |
| Legacy pending-key provider accepts an unfinishable regenerated CSR |
**Fixed by explicit capability validation.** A provider implementing
only `IPendingCertificateKeyStore` receives `BadNotSupported` before key
generation or persistence when regeneration is requested. Existing-key
CSR/update remains supported. Regeneration requires matching-only
consumption; no unsafe destructive probing was introduced. |
`LegacyPendingProviderRejectsRegenerationBeforeCreatingOrSavingAKeyAsync`
checks no key or store mutation;
`LegacyPendingProviderCompletesExistingKeySigningAndUpdateAsync` proves
the supported flow completes. |
| Shutdown waits behind user deactivation | **Fixed.** Accepted
deactivations drain before the master's lifecycle gate is taken.
Configuration retains its binding until the shared drain completes,
including concurrent shutdown callers and direct master disposal. | Four
cases of `ShutdownDrainsDeactivationOutsideTheSessionClosingGateAsync`
exercise real configuration/binding/ServerInternalData/master reentry,
retained address space, exact session/subscription closure, and no
production cancellation shortcut. |
| Subclass cleanup runs under the operation-lifetime lock | **Fixed.**
Cleanup starts outside that lock. A pre-published completion joins all
disposers, and only the active asynchronous teardown context may use
guarded helpers. Captured contexts lose that privilege when the callback
exits. |
`SynchronousOwnedCleanupLetsSamplingWorkerReachClosedAdmissionAsync`,
`DisposeCoreCanUseGuardedMembersWithoutReopeningCallerAdmissionAsync`,
captured-context rejection, and exact cleanup-failure/ownership
assertions cover both synchronous and suspended cleanup. |
| Discovery requestId-zero chunk read after pool return | **Fixed by the
earlier merge.** Discovery validation precedes `SaveIntermediateChunk`;
no read occurs after that path can return the buffer. The ordering is
retained. | `DiscoveryChannelReadsTheFirstChunkBeforeItIsReturnedAsync`
uses a poisoning pool and the requestId-zero path. |
| T1.8 OPN cleanup misses post-decryption throws | **Fixed by the
earlier merge and pinned with new regressions.** Both sequence rejection
and certificate mismatch release the decrypted body and input ownership.
| `OpenSecureChannelRejectionAfterDecryptionReturnsEveryRentalAsync`
checks the exact audit status and pool conservation. Omitting either
corresponding release in an isolated snapshot made its case fail; no
mutation remains. |
| T1.2 mount-root protection depends on the provider | **Fixed at the
host boundary.** Separator-only aliases resolve to the protected root.
Traversal, empty/whitespace segments, backslashes, and drive/stream
qualifiers are rejected before arbitrary providers are invoked. Valid
nested paths and root destinations remain supported. | A deliberately
permissive provider is used by
`NonCanonicalObjectPathsCannotDeleteMoveOrCopyAsync` and invalid-target
cases; exact zero-call checks are paired with legitimate
nested-path/root-destination controls. |
| Inline trusted/issuer entries now face CRL checks | **Intentional
strict behavior, documented and tested.** A configured backing store
with strict unknown-status rejection requires current revocation
information. Explicit-only lists without a store retain their no-store
behavior. Actual revocation cannot be suppressed; a matching store
entry's policy takes precedence. |
`InlineIssuerWithStoreHonorsStrictUnknownRevocationPolicyAsync`,
`InlineIssuerRevocationIsNeverSuppressedAsync`, store-precedence cases,
and explicit-only controls preserve the missing-information versus
actual-revocation distinction in Part 4 6.1.3. |
| RSA minimum applied to unspecified ECC identifiers | **Fixed at the
application-certificate check.** The RSA minimum is compared only with
an actual RSA public key. An unspecified ECC identity is not replaced
simply because its key size is below an RSA threshold. |
`SilentCertificateCheckPreservesUnspecifiedEccIdentityAsync` checks DER,
thumbprint, store contents, and private-key retention; explicit RSA and
HTTPS minimum controls remain. |
| Failed directory refresh wedges corrective mutations | **Fixed.**
Recoverable automatic pre-refresh failures are logged and leave
reconciliation pending, but do not block corrective provider mutations.
Explicit refresh errors, capacity admission, caller cancellation, and
actual provider denials remain enforced. |
`CorrectiveDeleteRecoversAfterPersistentOverLimitRefreshFailureAsync`
converges an externally over-limit directory back to its exact allowed
contents. Capacity, cancellation/token, and six refresh-error controls
check negative paths. |
| Timed-transition rejection logs at 10 Hz | **Fixed.** Repeated
`(state, revision, status)` warnings are deduplicated without changing
the retry cadence. A different status or state entry permits another
warning. | `RepeatedTimedRejectionLogsOnceAndRecoversAsync` checks one
warning over eleven rejections and success on the next attempt.
Status-change and same/different-state reentry controls pin reset
behavior. |
| Bad unsubscribe strands retired-generation bookkeeping | **Fixed.**
Retired all-event bookkeeping completes even when unsubscribe reports
Bad; the first Bad is still returned to the caller. | Good and
BadCommunicationError cases of
`RetiredAllEventsDeletionCompletesBookkeepingAndReleasesGenerationAsync`
check event/snapshot removal, generation release, and exactly one
unsubscribe without finalization replay. |
| CSR subject parsing and fallback | **Fixed for existing and
regenerated keys.** Omitted subjects fall back to the actual stored
certificate. Regeneration resolves the public certificate's subject
before creating a key. Malformed or unresolvable subjects return
`BadInvalidArgument` before pending-key mutation. | Store-only fallback,
real-generator null/empty cases, and missing-subject/malformed-DN
controls verify the signed CSR, matching fresh pending key, unchanged
active identity, and no destructive side effects. |
| Committed RemoveUser change reported as failed | **Fixed for
RemoveUser and ModifyUser.** Accepted cleanup is still awaited and close
failures remain logged, but cannot replace the committed mutation
result. Precommit failures retain their original result and side
effects. |
`CommittedUserChangeKeepsResultAndPublishedUsersWhenCloseFailsAsync`
checks published Users, Good result, logging, and continued closes.
`FailedUserChangeKeepsPublishedUsersAndDoesNotCloseSessionsAsync` checks
BadNotFound with no publication or close. |
| Alleged pooled decoded-request aliasing | **No runtime redesign
needed; reviewer rebuttal confirmed.** The decoder copies the relevant
input data. Misleading ownership comments were corrected rather than
introducing a new dispatch model. |
`DecodedRequestRetainsValuesAfterInputBuffersAreReturnedAsync` checks
the exact decoded NodeId and ByteString after returned arrays are
poisoned. |

The current documentation-only follow-up rechecked **104 Core, 473
Server, and
4 Configuration cases** covering these concerns: **581 passed, zero
failed or
skipped**. The final net10 solution build also passed with **zero
warnings and
errors**. The complete earlier Core/Server/Configuration and net48
results remain
listed separately rather than being presented as rerun current-head
acceptance.

## Earlier review follow-up

Commit `b8a59990fe2e8e967a12ffd5d9158beed7831143` addresses all 19
review
threads. Each thread has an in-thread reply naming this commit and was
explicitly
resolved after verification; the final paginated query found 19 resolved
and
zero unresolved threads.

The follow-up fixes injected certificate-store selection (including
default
provider ordering and native X509 paths), actual-RSA minimum
enforcement, and
CSR capability/subject validation before key mutation. Node-manager
teardown
uses simpler scoped ownership and drains accepted user revocation
outside the
master gate; committed user results and retired-event cleanup remain
correct
when cleanup reports failure. File mutations can recover after failed
refresh
without bypassing capacity or cancellation, provider paths protect mount
roots,
and repeated timed rejections no longer flood logs. Leadership
subscriber
failures are isolated without callbacks from `IsLeader`.

Regressions also pin the already-merged OPN/discovery buffer fixes and
prove
decoded request values do not borrow returned input buffers. The strict
inline
issuer revocation policy is documented and tested, not weakened.
Existing tests
were changed only for the reviewed behavior: the incorrect
committed-user result
was corrected, and inline-issuer/root-alias controls were expanded.

### Review-fix validation

| Suite | Scope | Framework | Passed | Existing TRX NotExecuted |
| --- | --- | --- | ---: | ---: |
| Core | Complete project | net10.0 | 4,970 | 88 |
| Server | Complete project | net10.0 | 6,414 | 69 |
| Configuration | Complete project | net10.0 | 231 | 0 |
| Core | Certificate, lease and buffer compatibility slice | net48 | 153
| 0 |
| Server | Reviewed lifecycle, user, file, timer and CSR compatibility
slice | net48 | 692 | 0 |
| Configuration | Complete project | net48 | 231 | 0 |
| CSR provider regressions | Final portable-mock follow-up | net10.0 |
11 | 0 |

All runs exited successfully with normal concurrency and leak detection.
The Server console reports 5 skips; its TRX additionally records
existing
assumption/explicit cases, hence the 69-record count above. The
Framework build
caught two modern-only `ValueTask.CompletedTask` usages in new mock
setup; only
those expressions changed to the equivalent portable
`default(ValueTask)`.
The complete modern suites precede that test-only correction; all
production
files remained byte-identical, and the corrected fixture passed on both
targets.

The final net10 solution build succeeded with 0 errors and the same 4
CA2249
warnings in unchanged fuzz-test code. Diff checks and changed-line
whitespace
checks are clean; whole-file formatting still identifies 10 unchanged
baseline
lines, which were not reformatted outside the feedback scope. The
committed
35-file tree was hash-verified against the validation inputs.

This is the review-feedback verification scope, not a rerun or sign-off
of the
historical complete platform/native/soak acceptance campaign below.


## Latest upstream merge

Merge `9d9bf679f317164c5b2b90924cd3cdc27a86d451` integrates `master`
through `162fc5ebb` (#4486),
including the AddNodes child-name index, shared certificate-error
reporting,
CTT sample-data corrections, and the accompanying regressions and
documentation.

The CreateSession conflict preserves this branch's certificate
ownership,
required certificate, application-URI, and channel-certificate checks.
The
validation result now retains nested errors through `ThrowIfInvalid`,
allowing
the upstream shared reporting helper to mask trust, policy, and
revocation
failures consistently without losing audit detail.

The new indexed AddNodes regression initially attempted to reuse a
deterministic
NodeId after renaming its existing node. Renaming frees the BrowseName,
not that
NodeId. `IndexedBrowseNameReusePreservesExistingNodeIdentityAsync` now
proves
both contracts: the derived-ID collision leaves the original node
intact, then
a fresh explicitly requested ID can reuse the old name while the new
name
remains protected against duplicates. No production identity rule was
relaxed.
The incoming seeded random-index test uses the existing `UnsecureRandom`
wrapper
with the same seed and sequence; no new analyzer suppression was added.

| Local merge validation | Framework | Passed | Existing exclusions |
| --- | --- | ---: | ---: |
| Core, complete project | net10.0 | 5,081 | 88 |
| NodeState / BaseInstanceState | net10.0 | 579 | 0 |
| Server node management, certificate and session-security selection |
net10.0 | 112 | 0 |
| InformationModel node-management services | net10.0 | 29 | 1 |
| NodeState / BaseInstanceState | net48 | 578 | 0 |
| Core certificate validation, error reporting and TCP buffers | net48 |
111 | 0 |
| Server node management, certificate and session-security selection |
net48 | 112 | 0 |

All selected runs exited successfully at normal concurrency: **6,602
passing
test executions**. Modified test assemblies were explicitly rebuilt, and
the
combined name/identity regression was verified by name in both net10 and
net48
results. No skips or weakened assertions were introduced.

The final `dotnet build UA.slnx -c Release --no-restore -m:1` with
`CustomTestTarget=net10.0` succeeded with **zero warnings and zero
errors**.
The resolved source and adjusted tests pass whitespace verification, and
their
source hashes match the validation inputs.

This is merge-specific verification. Earlier review results and the
previously
reported incomplete full Subscriptions runs below remain scoped to their
own
commits; they were not reopened or relabeled as passing by this
operation.

## Previous upstream merge (2026-09-15 morning)

Merge `c4694e20ab089c555af233642042133ff3a03ac8` integrates `master`
through `484d6c887`, retaining
#4481's shared Like parser/GDS query fixes, #4488's RequestHandle
recovery in
ServiceFaults, and #4487's expired-session and durable-transfer
corrections.

Three conflict resolutions preserve both sides:

- Alias searches now use the shared `LikePattern` implementation,
including
upstream malformed-list validation. A timeout overload retains this
branch's
100 ms per-alias deadline and `BadTimeout` reporting without keeping a
second
wildcard translator. The shared matcher's original unlimited overload
remains
unchanged for existing GDS/ContentFilter callers. New regressions cover
bounded prefix/list scans, reuse after timeout, and the alias service
result.
- Expired activation closes outside the session-manager gate using
upstream's
once-only timeout claim, audit, and diagnostic accounting. This branch's
  original-channel and security-policy checks remain in place.
- TCP buffer tests retain this branch's ownership, poisoning-pool and
post-decryption checks alongside the upstream RequestHandle/fault
overload
  tests; neither set was discarded.

The upstream HTTPS binary behavior is intentionally retained: a decoder
`ServiceResultException` produces HTTP 200 with an OPC UA ServiceFault
carrying
the decoder status, response timestamp, and recoverable RequestHandle,
rather
than the previous HTTP 500 text response. Other exceptions retain their
existing HTTP error handling.

| Local validation | Framework | Passed | TRX NotExecuted |
| --- | --- | ---: | ---: |
| Core, complete project | net10.0 | 5,054 | 88 |
| Sessions, complete project | net10.0 | 780 | 610 |
| Server aliases, expiry/security and subscriptions | net10.0 | 288 | 0
|
| GDS database, paging and live directory calls | net10.0 | 263 | 0 |
| Shared Like patterns and bounded evaluation | net10.0 | 109 | 0 |
| Subscriptions transfer/durability/service selection | net10.0 | 23 | 1
|
| HTTPS WebApi, complete project | net10.0 | 346 | 0 |
| InformationModel aliases | net10.0 | 54 | 5 |
| Shared Like patterns and bounded evaluation | net48 | 109 | 0 |
| Core fault handles, TCP/HTTPS and filters | net48 | 268 | 0 |
| Server aliases, expiry/security and subscriptions | net48 | 288 | 0 |

These selected runs exited successfully with normal test concurrency:
**7,582 passing test executions**. Sessions' console reports 379 skips;
the
TRX also contains conditional/assumption exclusions, so the table uses
the
actual 610 NotExecuted records. No skips or weakened assertions were
added.
The new `EvaluationTimeoutBoundsWorkAndPreservesPatternReuse` and
`SharedPatternTimeoutRemainsAnAliasServiceError` tests passed on both
targets.

**Validation limitation:** two unfiltered Subscriptions runs did not
produce
a final result and were stopped, including a retry with a hang guard.
They
are not counted as passing. The isolated failover fixture passed 2/2,
and the
transfer/durability/service selection above passed. The stall's cause
was not
established; no unrelated code or test was changed to conceal it.

The final `dotnet build UA.slnx -c Release --no-restore -m:1` with
`CustomTestTarget=net10.0` succeeded with **0 warnings and 0 errors**.
Whitespace verification passed for all hand-reconciled files, and staged
source hashes match the validation inputs.

These are merge-specific checks, not a new full platform/native/soak
acceptance
sign-off. Earlier review and acceptance evidence below remains dated to
its
own commit.

## Previous upstream merge (2026-09-14)

Merge `c3e09553e4d1509502d4ba751960d58491bea2cf` integrates `master`
through `6313e38a0` (#4478),
including the CTT-driven Part 13 aggregate corrections and FindAlias
argument
validation. The upstream tests and CTT documentation are retained.

The single conflict in `AliasNameWildcardMatcher` is reconciled through
the
branch's shared regex compiler: upstream malformed-pattern rejection and
`IsValidPattern` coexist with OPC UA character escaping,
whole-string/newline
matching, the 100 ms timeout, and service-level error reporting. The
upstream
ReferenceTypeFilter checks and chronological aggregate behavior are
preserved.

Local merge verification:

| Suite / selection | Framework | Passed | Existing skips |
| --- | --- | ---: | ---: |
| Server aliases, aggregates and historian | net10.0 | 1,011 | 0 |
| Server aliases, aggregates and historian | net48 | 1,011 | 0 |
| InformationModel aliases | net10.0 | 54 | 5 |
| Features aliases | net10.0 | 11 | 0 |
| Client aliases | net10.0 | 28 | 0 |
| History aggregate/historical-access consumers | net10.0 | 265 | 0 |

All selected tests passed at normal concurrency. The final net10
solution
build passed with 0 errors and 0 warnings.
The resolved file's whitespace check and the staged diff check passed.
These
are merge-specific checks, not a new full platform/native/soak
acceptance claim.

## Previous upstream merges

Integrates `master` through `8e874d073`, including the Client, Types,
Core and
source-generator audit fixes in #4471, #4473, #4474 and #4475, plus the
event queue-size revision from #4480. The merge commits are `150ea0439`
(26 conflict resolutions) and `a0e17c07c` (the base update that arrived
during
validation).

The overlapping fixes are reconciled rather than replacing either
branch:

- **Rejected-certificate retention follows this branch, as explicitly
requested:**
zero means unlimited history; negative values disable new storage while
preserving existing backend trimming behavior (Directory clears prior
history;
shared key/value leaves it intact). The processor and manager policy are
not
  redefined, and documentation/tests describe the distinction.
- Preserve owned certificate snapshots and borrowed validation-core
lifetimes
  alongside upstream cache invalidation, store-first CRL checks and
  `BadCertificateRevocationUnknown` for unsupported revocation lookup.
- Combine exactly-once chunk ownership, receive-loop cancellation,
stale-epoch
rejection and detach-before-adoption with upstream
signature-before-padding
  verification and secret cleanup.
- Preserve pooled `HttpClient` per-request configuration together with
bounded
response reads and deadlines that remain active through body
consumption.
Regression cases exercise already-used clients and both timeout
precedence cases.
- Retain the binary-compatible channel-key constructor and normalize
omitted
profiles; preserve exact profile filtering and upstream binary-first
HTTPS/WSS
  endpoint ordering.
- Preserve monotonic lease expiry and stale-confirmation fencing while
adding
ordered, non-reentrant leadership notifications outside the state lock.

## Validation

Local validation of the conflict-resolution merge:

| Suite | Framework | Passed | Existing skips |
| --- | --- | ---: | ---: |
| Core | net10.0 | 4,940 | 88 |
| Client | net10.0 | 2,278 | 0 |
| Server | net10.0 | 6,088 | 5 |
| Types | net10.0 | 10,394 | 1 |
| SourceGeneration.Core | net10.0 | 3,940 | 8 |
| SourceGeneration | net10.0 | 185 | 0 |
| Core | net48 | 4,855 | 104 |

After the subsequent four-file #4480 update, the related Server slice
passed
811 tests with 5 existing skips, the real durable queue-size cases
passed 6/6,
and EventManager's 21 cases passed again after analyzer-only
expected-array
cleanup. The final `dotnet build UA.slnx -c Release` with
`CustomTestTarget=net10.0`
succeeded: 0 errors and 4 pre-existing CA2249 warnings in unchanged
fuzz-test
code. Whitespace verification passed for the resolved files and the
follow-up.

Merge-specific proof includes
`RequestTimeoutRemainsActiveWhileReadingTheOpenApiBodyAsync` on
already-used
shared clients with either the request or HttpClient timeout shorter,
the
existing ownership/expiry regressions from both branches, and both
branches'
retention tests adjusted only to the explicitly chosen zero/negative
contract.

Earlier acceptance at `1c4a9df6c` passed hosted Linux/macOS CI, CodeQL,
image
builds and the canonical coverage gate (87.15% line, 77.27% branch,
92.14% patch).
Both Framework project matrices, all three Windows native suites (183
cases),
and the unfiltered net10 stress project (230 cases, including full
60-minute
chaos and 30-minute memory soaks) also passed. Those are **pre-merge
results**,
not a claim that the full framework/platform/native/soak matrix has been
rerun
on this merge. The previous acceptance workflow remains separate from
this
review-feedback operation.

Security checks, meaningful assertions, leak detection, default native
concurrency, full soak durations and coverage thresholds remain intact.
The user-set ready-for-review state is unchanged. No architectural or
complete
merged-head acceptance sign-off is claimed.

## Related Issues

- Fixes #4476

## 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.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-09-16 04:42:09 +00:00

16 KiB

FileSystemClient — a System.IO-style async client for OPC UA file systems

FileSystemClient (in src/Opc.Ua.Client/FileSystem/, namespace Opc.Ua.Client.FileSystem) is an ergonomic, async-only wrapper around the OPC UA file-system primitives defined in Part 5 §C and Part 20 §4 — the FileType, FileDirectoryType, and TemporaryFileTransferType ObjectTypes. It is layered on top of the source-generated proxies emitted into Opc.Ua.Core (FileTypeClient, FileDirectoryTypeClient, TemporaryFileTransferTypeClient) and exposes a surface that closely mirrors System.IO.{File, Directory, FileInfo, DirectoryInfo, FileStream} to make remote file-system navigation feel like working with a local disk.

Quick reference

Type Mirrors Purpose
FileSystemClient System.IO.Directory + System.IO.File Top-level entry point. Exposes path-based operations (GetFileAsync, CreateDirectoryAsync, DeleteAsync, MoveAsync, CopyAsync, ReadAllBytesAsync, …). Rooted at any FileDirectoryType instance.
UaFileSystemInfo System.IO.FileSystemInfo Abstract base for UaFileInfo and UaDirectoryInfo. Carries the resolved NodeId, parent reference, browse name, and canonical path.
UaFileInfo System.IO.FileInfo A single file. Lazy-loaded Size / Writable / MimeType / etc. metadata; OpenAsync / OpenReadAsync / OpenWriteAsync returning a UaFileStream; bulk ReadAllBytes / WriteAllText shortcuts.
UaDirectoryInfo System.IO.DirectoryInfo A single directory. EnumerateAsync / EnumerateFilesAsync / EnumerateDirectoriesAsync (IAsyncEnumerable<>); CreateSubdirectoryAsync, CreateFileAsync, DeleteAsync(recursive).
UaFileStream System.IO.FileStream A Stream-derived wrapper around an open server file handle. Async members hit the wire via FileTypeClient; sync members forward via GetAwaiter().GetResult().
UaPath System.IO.Path Helpers for parsing, formatting, combining, and splitting OPC UA file paths.
FileSystemClientOptions n/a Tuning knobs: ChunkSize, MaxBufferedReadSize, PathCacheSize, type-subtype filters.
TemporaryFileTransferClient + UaTemporaryWriteFile n/a Separate surface for Part 5 §C.5 atomic temp-file transfers (GenerateFileForRead/Write + CloseAndCommit).

Getting started

Open the standard server file system

Many OPC UA servers expose the standard Server.FileSystem object (NodeId i=16314):

using Opc.Ua.Client.FileSystem;

ISession session = /* an active OPC UA client session */;

var fs = FileSystemClient.OpenServerFileSystem(session);

await foreach (UaFileSystemInfo entry in fs.EnumerateAsync("/"))
{
    Console.WriteLine($"{(entry.IsDirectory ? "DIR " : "FILE")} {entry.FullPath}");
}

Open any FileDirectoryType instance

NodeId vendorRoot = /* result of a Browse / TranslateBrowsePathsToNodeIds */;
var fs = new FileSystemClient(session, vendorRoot);

Read a file

string content = await fs.ReadAllTextAsync("/Reports/2024/summary.json");

Stream a large file

await using UaFileStream stream = await fs.OpenReadAsync("/Logs/server.log");
using var reader = new System.IO.StreamReader(stream);
string firstLine = await reader.ReadLineAsync();

Create a directory tree and write a file

UaDirectoryInfo dir = await fs.CreateDirectoryAsync("/Uploads/2024-05/test", createIntermediate: true);
await fs.WriteAllBytesAsync($"{dir.FullPath}/payload.bin", payload);

Move and delete

UaFileInfo file = await fs.GetFileAsync("/Drafts/v1.txt");
await file.MoveToAsync("/Published/v1.txt");

await fs.DeleteAsync("/Drafts", recursive: true);

Path syntax

Paths use the forward slash '/' as the segment separator. Each segment is parsed by QualifiedName.Parse(...), so the standard "<ns>:<name>" form is supported. Examples:

Path Meaning
"" or "/" The root directory
"foo" The child foo (namespace 0) of the root
"/foo/bar" Same as foo/bar; leading slash is optional
"1:Reports/1:2024/data.csv" Qualified path using namespace index 1 for the first two segments and namespace 0 for data.csv

UaPath.Combine, UaPath.GetDirectoryName, UaPath.GetFileName, and UaPath.Normalize mirror the equivalents on System.IO.Path. Canonical paths returned from UaFileSystemInfo.FullPath always include the namespace prefix when it is non-zero, so siblings with the same Name in different namespaces are never collapsed.

Path resolution and caching

Path → NodeId resolution uses TranslateBrowsePathsToNodeIdsAsync segment-by-segment, with each step following ReferenceTypeIds.HierarchicalReferences (with subtypes). (parent NodeId, browse name) → child NodeId mappings are cached in a small LRU controlled by FileSystemClientOptions.PathCacheSize (default 1024; set to zero to disable). The cache is best-effort: when a resolved entry stops working (BadNodeIdUnknown from the server), the entry is evicted and the path is re-resolved before the error propagates. After any successful create/delete/move/copy, the path-cache entries rooted at the affected parent are invalidated to avoid serving stale NodeIds.

Type classification (FileType vs FileDirectoryType)

Each child returned by EnumerateAsync is classified by its HasTypeDefinition reference. Subtype-aware classification uses Session.TypeTree.IsTypeOf(...) — by default subtypes count, so TrustListType, AddressSpaceFileType, ConfigurationFileType etc. appear as files. To restrict enumeration to the exact FileType / FileDirectoryType set:

var fs = new FileSystemClient(session, ObjectIds.FileSystem,
    new FileSystemClientOptions
    {
        IncludeFileTypeSubtypes = false,
        IncludeFileDirectoryTypeSubtypes = false,
    });

File metadata

UaFileInfo exposes the seven well-known FileType properties (Size, Writable, UserWritable, OpenCount, MimeType, MaxByteStringLength, LastModifiedTime). They are populated lazily by UaFileInfo.RefreshAsync (a single batched Read against the properties resolved via TranslateBrowsePathsToNodeIds). The optional properties (MimeType, MaxByteStringLength, LastModifiedTime) are returned as null when the server does not expose them (BadNoMatch, BadNodeIdUnknown, or empty target lists are tolerated).

Writable / UserWritable are advisory: callers should not pre-check them before opening a file. Rely on the server's Open response for the authoritative answer.

Streams

UaFileStream derives from System.IO.Stream and supports both async (ReadAsync, WriteAsync, DisposeAsync) and sync (Read, Write, Dispose) members. The sync members forward to the async ones via GetAwaiter().GetResult() — this means they can deadlock on single-threaded synchronization contexts (e.g. WPF UI thread). Prefer the async overrides.

Reads and writes are chunked at FileSystemClientOptions.ChunkSize, clamped down to FileType.MaxByteStringLength when the server advertises a smaller maximum. Empty ByteString returns are interpreted as EOF; zero-length reads/writes never hit the wire.

Position is tracked locally; the server is informed via SetPosition only when the local cursor diverges from the last successfully transmitted position. Length is tracked locally too — opened from FileType.Size at construction and bumped whenever a write extends past it. Callers that mutate the underlying file through other handles (or other clients) should call UaFileInfo.RefreshAsync() before relying on Length.

UaFileStream is not thread-safe in the sense that multiple threads can share a single instance: concurrent calls are serialised by an internal SemaphoreSlim, matching FileStream's "not thread-safe but doesn't corrupt" contract. DisposeAsync issues FileType.Close exactly once even when called concurrently.

Server-issued file handles are opaque and bound to the Session that opened them. They cannot be used by another Session, and the server automatically closes all remaining handles when their owning Session closes.

Error mapping

OPC UA Bad status codes returned by the server are translated into the familiar System.IO exception family at the public boundary:

StatusCode Mapped exception
BadNoMatch, BadNodeIdUnknown, BadNotFound FileNotFoundException (files) / DirectoryNotFoundException (directories)
BadBrowseNameDuplicated IOException("already exists: …")
BadUserAccessDenied, BadNotWritable, BadWriteNotSupported, BadSecurityChecksFailed UnauthorizedAccessException
BadInvalidArgument, BadOutOfRange, BadInvalidState, BadResourceUnavailable, BadOutOfMemory IOException
Other Bad… codes ServiceResultException (preserved unchanged)

Mapped exceptions wrap the original ServiceResultException as InnerException, so callers that need the raw OPC UA status code can still retrieve it.

TranslateBrowsePathsToNodeIds returning more than one target throws IOException("ambiguous path: …"); the client never silently picks the first match.

Move, copy, and delete semantics

These three operations are routed through the source's parent directory (per Part 20 §4.3) — the server's MoveOrCopy and Delete methods take a NodeId and operate from the directory's perspective.

DeleteAsync(recursive: false) on a directory first enumerates the directory and throws IOException("directory not empty: …") if any child exists. DeleteAsync(recursive: true) invokes the server's Delete exactly once and lets the server perform recursive removal (per spec). The client never walks the tree itself for a recursive delete — that would weaken the server's atomicity / locking guarantees and double-traverse.

CreateDirectoryAsync and CreateFileAsync accept only a plain string for the leaf name; they reject leaf segments with a namespace prefix (the server picks the BrowseName namespace). After the server returns the new NodeId, the client reads its actual BrowseName and uses that for the canonical path / cache entry.

CreateFileAsync always passes requestFileOpen: false to the server — we never leak a server-allocated handle out of the create call. Callers that want an immediate stream should call UaFileInfo.OpenAsync afterwards.

Temporary file transfer (Part 5 §C.5)

The temporary-file-transfer pattern is exposed on a separate surface because its lifecycle does not fit the System.IO abstraction. The server allocates a transient file, the client streams data through it, and a final commit (or rollback) tells the server to either publish or discard the result.

using Opc.Ua.Client.FileSystem;

NodeId transferObject = /* TemporaryFileTransferType instance */;
var temp = new TemporaryFileTransferClient(session, transferObject);

// Read flow
await using UaFileStream readStream = await temp
    .GenerateFileForReadAsync(generateOptions: default);
byte[] payload = ReadAll(readStream);

// Write flow
await using UaTemporaryWriteFile write = await temp.GenerateFileForWriteAsync();
await write.Stream.WriteAsync(payload, 0, payload.Length);
NodeId completion = await write.CommitAsync(); // CloseAndCommit

UaTemporaryWriteFile owns the close lifecycle: exactly one terminal call — CommitAsync (CloseAndCommit) or DisposeAsync (Close, implicit server rollback) — is sent to the server. The wrapped Stream cannot accidentally close the server handle; its Dispose is a no-op.

What lives where

src/Opc.Ua.Client/FileSystem/
├── FileSystemClient.cs                 # entry point
├── FileSystemClientOptions.cs          # tuning knobs
├── FileMetadata.cs                     # internal property snapshot
├── FileSystemErrors.cs                 # status code → IO exception mapper
├── PathCache.cs                        # internal LRU
├── UaPath.cs                           # System.IO.Path mirror
├── UaFileMode.cs                       # OpenFileMode flags wrapper
├── UaFileSystemInfo.cs                 # abstract base
├── UaFileInfo.cs                       # System.IO.FileInfo mirror
├── UaDirectoryInfo.cs                  # System.IO.DirectoryInfo mirror
├── UaFileStream.cs                     # System.IO.Stream mirror
├── TemporaryFileTransferClient.cs      # Part 5 §C.5 surface
└── UaTemporaryWriteFile.cs             # commit/rollback wrapper

Tests live under tests/Opc.Ua.Client.Tests/FileSystem/.

Server-side file bindings

Both lazy FileSystemNodeManager nodes and materialized FileDirectoryBinder nodes protect their mounted root from deletion, move, and copy. A NodeId from another namespace, an invalid encoded type, or a component identifier cannot alias a file-system object.

Before Delete or MoveOrCopy reaches an arbitrary provider, the lazy host validates the decoded provider path. Separator-only root aliases, including backslashes, resolve to the protected empty root. Nonroot paths must use forward slashes and contain no empty, . or .. segments, backslashes, or drive/stream qualifiers. Valid relative paths are passed unchanged. The root remains a valid move/copy destination for a child, but never the object being deleted, moved, or copied.

FileType.Read requires a positive length and may return fewer bytes than requested. The server advertises MaxByteStringLength and bounds each read by the file's limit, the server byte-string limit, and the available single-result binary response budget. If server limits are disabled, the default encoding byte-string limit still bounds individual read allocations. At EOF, a valid positive-length read returns an empty byte string. Service calls use the asynchronous read handler; legacy direct synchronous delegates remain available.

File opens reserve compatible access before invoking the provider, so a rejected erase request cannot truncate a file held by another opener. Failed or cancelled opens release their reservations; streams returned after session closure or disposal are closed rather than published. Read and Write enforce the requested open mode even if the provider's stream supports both operations. SetPosition clamps positions beyond EOF to the current file length. Writable describes the provider/file capability and does not become false merely because a handle is open.

Materialized directory bindings serialize capacity admission, provider mutations, refresh, and teardown. Creates and cross-directory moves/copies are rejected before touching the provider when the target directory is already at MaxEntries; a rename within the same directory needs no extra slot. Lookup snapshots are published only after node registration completes. Teardown retires lookups and handles before provider cleanup and drains queued operations before disposing its synchronization resources.

If a provider mutation commits but the subsequent refresh fails, the mutation remains successful and an error identifies the unsynchronized binding. RefreshAsync retries reconciliation, as does the next mutation before it changes the provider again. A recoverable pre-mutation refresh failure is also logged, without blocking a corrective mutation such as deleting an entry after external changes exceeded MaxEntries. The error records whether the current provider mutation has committed. Capacity admission and provider errors still apply; cancellation before commit still stops the operation. Reconciliation does not truncate over-limit directories, and an explicit RefreshAsync still reports its failure to the caller. Failed registration callbacks are retried rather than treating an unregistered node as complete.

See also

  • SourceGeneratedDataTypes.md — for an overview of the generated *TypeClient proxies the FileSystem client builds on.
  • OPC UA Part 5 Annex C (FileType, TemporaryFileTransferType).
  • OPC UA Part 20 §4 (FileSystem object model).