FileSystemClient

May 14, 2026 · View on GitHub

FileSystemClient (in Libraries/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

TypeMirrorsPurpose
FileSystemClientSystem.IO.Directory + System.IO.FileTop-level entry point. Exposes path-based operations (GetFileAsync, CreateDirectoryAsync, DeleteAsync, MoveAsync, CopyAsync, ReadAllBytesAsync, …). Rooted at any FileDirectoryType instance.
UaFileSystemInfoSystem.IO.FileSystemInfoAbstract base for UaFileInfo and UaDirectoryInfo. Carries the resolved NodeId, parent reference, browse name, and canonical path.
UaFileInfoSystem.IO.FileInfoA single file. Lazy-loaded Size / Writable / MimeType / etc. metadata; OpenAsync / OpenReadAsync / OpenWriteAsync returning a UaFileStream; bulk ReadAllBytes / WriteAllText shortcuts.
UaDirectoryInfoSystem.IO.DirectoryInfoA single directory. EnumerateAsync / EnumerateFilesAsync / EnumerateDirectoriesAsync (IAsyncEnumerable<>); CreateSubdirectoryAsync, CreateFileAsync, DeleteAsync(recursive).
UaFileStreamSystem.IO.FileStreamA Stream-derived wrapper around an open server file handle. Async members hit the wire via FileTypeClient; sync members forward via GetAwaiter().GetResult().
UaPathSystem.IO.PathHelpers for parsing, formatting, combining, and splitting OPC UA file paths.
FileSystemClientOptionsn/aTuning knobs: ChunkSize, MaxBufferedReadSize, PathCacheSize, type-subtype filters.
TemporaryFileTransferClient + UaTemporaryWriteFilen/aSeparate 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:

PathMeaning
"" 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.

Error mapping

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

StatusCodeMapped exception
BadNoMatch, BadNodeIdUnknown, BadNotFoundFileNotFoundException (files) / DirectoryNotFoundException (directories)
BadBrowseNameDuplicatedIOException("already exists: …")
BadUserAccessDenied, BadNotWritable, BadWriteNotSupported, BadSecurityChecksFailedUnauthorizedAccessException
BadInvalidArgument, BadOutOfRange, BadInvalidState, BadResourceUnavailable, BadOutOfMemoryIOException
Other Bad… codesServiceResultException (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

Libraries/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/.

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