Files
UA-.NETStandard/docs/FileSystemClient.md
T
Marc Schier 2836da7ec8 Fix sessionless ResultTransfer ownership bypass and other issues. (#4834)
# Description

Correct ownership, endpoint validation, policy propagation, subscription
delivery, file handling and generated-model behavior across the stack,
tools and samples. Add focused regression tests and update the
corresponding guides and sample documentation.

### Previously included corrections

- Require an active, matching session to generate or access transient
ResultTransfer file handles. Cover both sessionless generation and reads
against a handle owned by an active session, using live session owners
in the Machinery tests.
- Keep physical filesystem operations within their configured mount and
apply the same confinement checks throughout file operations.
- Validate LDS semaphore paths consistently during server registration
and registration-store maintenance.

### Individual issues addressed

| # | Component | Issue and correction |
|---|---|---|
| 1 | AI jobs and transfers | Jobs and transfers did not consistently
retain their creator's identity and session. Apply ownership checks to
descendants, file handles, subscriptions and execution, and recheck
deployment permissions before dispatch. |
| 2 | Password-management client | Password-bearing calls could use
signing-only channels or follow a replacement connection. Capture an
encrypted transport generation and keep dispatch bound to it; preserve
ordinary non-password edits. |
| 3 | GDS and certificate-push clients | Connections omitted hostname
verification. Validate the original configured DNS name or IP address
before session activation. |
| 4 | Certificate push | Private-key uploads could use signing-only
calls. Require an encrypted, generation-bound dispatch while preserving
certificate-only updates and restoring the caller's identity afterward.
|
| 5 | MQTT client | The .NET Framework custom-trust callback could
discard TLS hostname, certificate-availability and usage errors. Retain
those checks while supporting configured issuer trust. |
| 6 | DTLS handshake | Certificate trust was not tied to the configured
peer address. Match the certificate's DNS or IP identity to the intended
endpoint. |
| 7 | Replicated address spaces | Serialized nodes lost per-node and
descendant permission metadata. Preserve role permissions, user-role
permissions and access restrictions, including updates to existing
replica nodes. |
| 8 | MCP PubSub imports | Imports without groups skipped checks on the
effective broker destination. Check the actual endpoint before resolving
credentials or activating the connection. |
| 9 | Reference-server accounts | A disabled username account could
authenticate again with its previous roles. Reject disabled accounts
during fresh authentication. |
| 10 | Console reference client | The sample disabled hostname checks
when connecting to an otherwise trusted peer. Retain the original
requested host and validate it before activation. |
| 11 | Capture replay and export | Replay and raw export could accept
non-capture files or include key material. Require complete PCAP/PCAPNG
containers and exclude key sidecars and decryption-secret blocks. |
| 12 | PubSub adapter fallback | The fallback client configuration
automatically accepted peer certificates. Apply normal trust and
hostname validation before activation or reads. |
| 13 | Kafka transport | Explicit TLS enablement was checked but did not
reach both final Confluent configurations. Use the same effective
protocol for validation, producers and consumers. |
| 14 | PubSub actions | Configuration rebuilds could widen a
connection-local action handler's scope. Preserve its original
connection association and reject mismatched dispatch. |
| 15 | PubSub readers | Reader-level message-protection settings were
accepted but ignored. Apply the effective group and reader settings
before accepting and publishing received values. |
| 16 | PubSub key groups | Different `SecurityGroupId` values could
share one connection-wide key context. Reject mixed groups on a
connection and require separate connections for distinct groups. |
| 17 | MCP session registrations | Session-name replacement or reuse
could retain another registration's credential bindings. Bind access to
the registration, caller and authenticated endpoint, and preserve that
identity when borrowing sessions. |
| 18 | Viewer HTTP host | Requests reached the viewer's OPC UA session
without consistent Host, Origin and port checks. Validate the request
destination and browser origin before using the session, while retaining
supported local MCP clients. |
| 19 | Vision file operations | File operations did not consistently
apply protected-file checks, and remote handles lacked caller and
connection context. Apply the shared checks and use host-side opaque
handles bound to caller, target and generation. |
| 20 | DTLS record integrity | The integrity-only MAC used a truncated
sequence number. Authenticate the full reconstructed sequence number and
epoch so records remain distinct across rollover. |
| 21 | AMB client sample | Connection setup omitted hostname validation.
Preserve the originally requested host and verify the peer identity
before activation. |
| 22 | Robot sample writes | The R2 `SpeedOverride` callback could
target R1 state. Bind each callback to its own robot and apply that
robot's write policy. |
| 23 | AI result properties | Optional result children inherited
remotely writable defaults. Preserve their declaration restrictions and
keep server-produced results read-only. |
| 24 | WSS peer-trust updates | Existing channels did not consistently
respond to peer-trust changes. Revalidate complete chains for forward
and reverse WSS channels and close only those that no longer validate. |
| 25 | GDS revocation checks | Caller-provided certificates could cause
issuer retrieval from embedded URLs. Resolve validation from configured
local trust, issuers and CRLs without fetching caller-provided
locations. |
| 26 | GMS observations | Published correction observations retained
writable model defaults. Reject remote writes while preserving
publisher-side updates. |
| 27 | Production-dataset monitoring | Subscription admission and
delivery could use the reporter's context instead of each subscriber's
permissions. Evaluate the subscriber at admission and on every delivery,
including cancellation and disposal paths. |
| 28 | WoT MQTT destinations | Hostless MQTT forms could imply a
localhost destination before loopback checks. Reject them before
credential lookup or connection attempts and validate the effective
endpoint. |
| 29 | XRegistry opaque reads | Content-ID reads and notifications did
not consistently honor encrypted-read requirements or the currently
accepted transport. Recheck the bound endpoint and reject transport
downgrades before rebinding, including when protected values are already
queued. |
| 30 | PubSub capture dissection | Key loading did not consistently
check the configured capture-root boundary. Check each actual key path
immediately before opening it. |
| 31 | PubSub publication queues | Imported WriterGroup queues were not
fully checked against publish-topic grants. Check the destination
actually used by each group. |
| 32 | MCP PubSub mutations | A caller-supplied mode could select
subscribe grants for an operation that publishes. Derive the required
direction and parent context from the actual mutation. |
| 33 | Generated write masks | Model import and generation could widen
an explicit `UserWriteMask`. Preserve it through import, copy, merge,
inheritance and dependency metadata. |
| 34 | Generated method flags | An explicit `UserExecutable=false` could
be lost. Preserve its specified state and resulting method behavior
through the same model paths. |
| 35 | Generated role identifiers | Unresolved role identifiers could
cause permission entries to be omitted. Preserve namespace-qualified
role IDs and report a diagnostic when a policy cannot be represented. |
| 36 | Aggregation-server defaults | The `localhost` default did not
reliably constrain listener binding. Use numeric loopback for the
default endpoint and command-line fallback. |
| 37 | Certificate-validation cache | Cached results could skip current
leaf or issuer validity checks. Recheck validity windows on cache hits
and keep hostname validation independent of cached chain trust. |
| 38 | Software-update folders | Revision names and marker contents
containing dot segments could select paths outside the software root.
Normalize and check every derived path against that root. |
| 39 | USDA arrays | Array elements were not encoded for their string or
asset context. Escape them as data and preserve valid composition and
round-trip behavior. |
| 40 | Adapter hot reload | A changed dataset definition with the same
name could keep old fields, subscriptions or cached values active.
Compare complete immutable definitions and retire old state before
replacement. |
| 41 | Adapter index ranges | Subscription publication could ignore
`IndexRange` and return the complete value. Preserve the requested
projection and its status and timestamps; reject unsupported range
providers. |
| 42 | PubSub metadata and targets | Metadata from one connection could
change another connection's target mapping. Scope metadata to its
connection and provenance, and resolve targets using actual typed field
IDs rather than field names or unintended positional fallback. |
| 43 | Scales product locks | Product locks were not applied across
every remote mutation path. Apply them consistently to value writes,
filling calculations and related recipe/controller operations while
retaining permitted updates. |
| 44 | Event permission caching | Cached event decisions could outlive
identity, role or node-policy changes. Revalidate the current
subscriber, event type and source permissions instead of reusing a stale
decision. |
| 45 | WoT Modbus destinations | Hostless Modbus forms could imply
localhost before loopback checks. Reject them before connecting and
validate the effective endpoint. |
| 46 | MCP local PKI tools | Peer-trust mutations did not consistently
require the caller's `CertificateAdministration` grant. Check the
capability before trust and untrust operations. |
| 47 | Robot-control HTTP bridge | The bridge lacked consistent Host,
Origin and port validation and caller-specific session grants. Apply
those checks before borrowing the OPC UA session and retain supported
local clients. |
| 48 | Subscription transfers | Transferred subscriptions could retain
cached notifications after the destination's permissions changed.
Require authorization continuity between source and destination
identities and roles, invalidate stale retransmission data when
continuity fails, and preserve retransmission behavior for compatible
transfers. |

### Compatibility notes

- Replication uses the versioned NSRP v1 policy envelope. Upgrade peers
together and re-encode or reseed legacy topology from the authoritative
model; missing permission metadata is not inferred from old records.
- Integrity-only DTLS peers must be upgraded together for the full
sequence/epoch MAC. AEAD behavior is unchanged.
- Distinct PubSub key groups require separate connections.
- Custom sessions that cannot capture the actual encrypted transport,
and subscription providers that cannot honor a requested range, are
rejected rather than silently changing the requested behavior.
- Subscription transfers retain retransmission data only when the
destination authorization remains compatible with the source snapshot;
otherwise the old sequence numbers and cached data are invalidated.

## Related Issues

No separate tracking issue is linked. The individual corrections are
listed above.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] 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.
- [ ] 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.
- [ ] I have addressed **all** PR feedback received.
2026-10-21 05:37:29 +02:00

28 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).
SessionFileHandle n/a Opaque, generation-bound handle for bounded transfers that must not follow a replacement session.

Contents

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);

Use a file known by its NodeId

A FileType NodeId can come from somewhere other than a path below the root, for example from a reference or a method result. Create the UaFileInfo directly instead of falling back to the raw FileTypeClient proxy:

// The containing directory is known: every operation is available and
// the path is the directory's path followed by the BrowseName.
UaDirectoryInfo reports = await fs.GetDirectoryAsync("/Reports");
var report = new UaFileInfo(reports, reportNodeId, new QualifiedName("summary.json"));
await report.RefreshAsync();
await report.DeleteAsync();

// Only the NodeId is known: the file is detached. It can be refreshed,
// opened, read and written, but DeleteAsync, MoveToAsync and CopyToAsync
// throw InvalidOperationException, because those are methods of the
// containing FileDirectoryType.
var result = new UaFileInfo(fs, resultFileNodeId, new QualifiedName("result.bin"));
byte[] bytes = await result.ReadAllBytesAsync();
bool detached = result.IsDetached; // true; Parent is null

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).

In the stack's FileSystemNodeManager, metadata reads and initial monitored-item values use the asynchronous provider binding. These reads do not allocate retained file-handle state or open a stream. The first Size notification contains the file's current byte count and status, not a stored null placeholder.

When the manager reattaches an item or re-enables a disabled item, it also reads fresh metadata. Initial reads do not add periodic polling for external file changes. Later notifications still require the node's normal change notification mechanism.

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. Path-based WriteAllBytesAsync and WriteAllTextAsync refresh metadata for existing and newly created files before opening them, so these helpers honor the same per-file chunk limit as OpenWriteAsync.

Position is tracked locally; the server is informed via SetPosition only when the local cursor diverges from the last successfully transmitted position. After a failed read or write, the server cursor is treated as unknown: the next operation sends SetPosition to the last locally confirmed position before transferring more bytes. This prevents a lost response from causing a retry to skip or duplicate a chunk. Successfully completed earlier chunks remain reflected in 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.

FileNodeId and Handle expose the server-allocated identities for companion methods that exchange metadata for an open transfer, such as OPC 40083 production datasets. They do not transfer lifecycle ownership: still commit or dispose the wrapper rather than closing the handle separately. Cleanup after a closed session uses the normal BadSecureChannelClosed protocol failure instead of dereferencing a missing channel.

For a bounded generated-file download, CopyToAsync accepts a caller-owned writable stream and a non-negative byte limit:

var temp = new TemporaryFileTransferClient(managedSession, transferObject);
long copied = await temp.CopyToAsync(
    generateOptions: default, destination, maxBytes: 64 * 1024 * 1024, ct);

With a ManagedSession, this operation captures one SessionRequestContext for generation, readiness polling, copying and cleanup. Recovery invalidates the operation instead of sending the old handle to a replacement session. An already-dispatched generation response is retained even if the caller cancels before it returns. A valid returned file node and handle are retained for cleanup before non-Good statuses or malformed response metadata are rejected. Close calls the standard FileType.Close declaration directly, without method discovery, using an independent five-second deadline. Transfer and cleanup failures are both reported when both occur.

The destination remains open and may contain partial data on failure. An ordinary or custom session remains caller-owned; prevent its replacement during the transfer. The operation allows at most 1024 file reads of at most 64 KiB (or a smaller configured chunk size), including the end-of-file probe, and at most 64 generation-state reads.

Generation-bound transfers

Use SessionRequestContext when retaining a remote file handle across calls. If the owning managed session recovers, the transfer fails rather than sending the old handle to its replacement.

using Opc.Ua.Client;
using Opc.Ua.Client.FileSystem;

await using SessionRequestContext context =
    await SessionRequestContext.CaptureAsync(managedSession, ct);
await using SessionFileHandle file = await context.OpenFileAsync(fileId, mode: 6, ct);
long bytes = await file.WriteFromAsync(source, maxBytes: 64 * 1024 * 1024, ct: ct);
CallMethodResult close = await file.CloseAsync(ct);

SessionFileHandle has no raw-handle constructor or import method and cannot be attached to another context. Use it sequentially like a stream and dispose it before the context. Disposal attempts Close only on the original session; cleanup failures are logged without replacing the transfer failure.

For whole transfers, context.UploadFromWithResultAsync and context.DownloadToStreamWithResultAsync return the byte count and unmodified Close result, including a refused Close. They enforce chunk and total-byte bounds, continue after short nonempty reads, and leave the caller's stream open.

Construct TemporaryFileTransferClient(context.Session, transferId) for generation-bound temporary transfers. Its context-taking generation overloads return SessionFileHandle; bounded UploadFromAsync and DownloadToAsync preserve the final method result. Upload returns the file ID, the completion-state-machine ID and the actual CloseAndCommit result. IsCommitted remains true if a successful commit has malformed output, so disposal does not close the consumed handle again.

Ordinary Close is not an atomic commit or rollback. A known committed result is not replaced by late cancellation. Existing raw-handle helpers remain compatible, but keep Open/Generate, every chunk and Close/CloseAndUpdate or CloseAndCommit on the same captured session; do not recreate a proxy from the newly current connection.

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
├── SessionFileHandle.cs                # opaque generation-bound transfer handle
├── 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

IFileTypeBinder / FileTypeBinder binds an already-created FileState to bounded in-memory content and an optional asynchronous commit callback, without a separate file-system node manager. It shares FileHandle ownership/mode rules and the existing response-size calculation. Bindings allow at most 64 open handles, one writer, and independent reader snapshots. Temporary writers commit through CommitAsync; commitOnClose selects the ordinary FileType close lifecycle. Application commit callbacks run without holding the binding's operation gate, so publication can refresh other file bindings. Disposal cancels and drains an active commit before releasing its handles. Machine Vision uses this surface for its specialized transfers and recipe files.

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.

Virtual directory browsing uses one provider cursor and preserves provider order across Browse/BrowseNext pages. It retains only the current entry and a continuation's lookahead reference. It does not snapshot the directory or apply the materialized binder's MaxEntries limit. Named-child lookup scans without retaining unrelated entries and stops when it finds a match.

The cursor is disposed exactly once on exhaustion, cancellation, or continuation release. Cancellation affects only the active page; cancelling an already completed page does not cancel later pages. Synchronous continuation release starts and observes asynchronous cursor cleanup without blocking its caller. The system surfaces provider failures instead of returning an empty directory.

Before Delete or MoveOrCopy reaches an arbitrary provider, the lazy host validates the decoded provider path. Separator-only root aliases resolve to the protected empty root. Nonroot paths use forward slashes and contain no empty, . or .. segments. On Windows, backslashes and drive/stream qualifiers are rejected; on Unix, colon and backslash are ordinary name characters and remain addressable. 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. This prevents a rejected erase request from truncating a file held by another opener. Failed or cancelled opens release their reservations. If a provider returns a stream after session closure or disposal, the system closes it instead of publishing it. Materialized FileDirectoryBinder bindings supplied with a ServerSystemContext attach to that server's session lifecycle on the first open. Explicit session closure and expiry release only that session's handles and pending reservations; other sessions keep their readers and positions. Late opens from a closing, expired, or removed session return BadSessionClosed. No historical session-ID list is retained. Binding disposal removes the lifecycle subscription, so the application must dispose the binding when its directory is removed or its node manager shuts down. A context without a server session manager has no automatic session lifecycle; such standalone bindings remain owned by their caller. 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.

Providers with multiple spellings for one file can implement IFileSystemPathIdentityProvider. The physical provider uses Windows case-insensitive identity and Win32 trailing-dot/space normalization, so alternate NodeIds share OpenCount and writer exclusion. Metadata reads query the provider asynchronously without allocating retained handle state. Closed or failed opens release idle handle bags; pending opens remain protected by their reservations. Extended Windows roots (\\?\...) retain literal trailing-dot names rather than conflating distinct directory entries.

Physical provider confinement

PhysicalFileSystemProvider rejects paths outside its configured root and rejects symbolic links, Windows junctions, and other reparse points with UnauthorizedAccessException. This no-link policy applies even to links whose targets are inside the mount, dangling links, and the configured root and every existing ancestor of that root. Use a physical root path without linked ancestors (for example, avoid the /var alias on systems where it is a symlink). The checks run before root creation and again on operations. On .NET 8 and later, the ReferenceServer resolves system temporary-directory aliases before selecting its default mount; provider-relative links remain rejected. Enumeration fails on a linked entry rather than exposing its target metadata. Recursive copy, move, and delete validate the source tree one directory level at a time, before entering any child directory. Copy validates its source tree before creating the destination; deletion does not use unchecked recursive traversal. Ordinary in-root files, directories, and creation of missing paths remain supported.

These are path-based checks, not a race-free filesystem sandbox. An untrusted local process could replace a checked file or ancestor between validation and I/O (TOCTOU). Protect the mount and its ancestors with host permissions so untrusted processes cannot rename, replace, or introduce entries while provider operations run. Existing open handles are not revalidated. Hard links and filesystem mount points that are not reported as reparse points are outside this policy; use a dedicated filesystem or OS-level isolation when local filesystem attackers are in scope. Recursive operations are not transactional and concurrent changes or I/O errors can leave partial results.

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.

IFileSystemProvider.CreateDirectoryAsync remains idempotent, including package-upload retries. The OPC UA CreateDirectory method checks the provider for an existing entry and returns BadBrowseNameDuplicated without creating or replacing it, independently of the provider.

FileDirectoryBinder.BindAsync registers the complete pre-existing tree before returning, including files on read-only mounts. Supply the registerNode and deregisterNode callbacks directly on its single signature. Disposal deregisters materialized nodes from leaves to roots, closes handles, and detaches file and directory callbacks. The supplied root remains owned by its original node manager.

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).