Files
UA-.NETStandard/docs/FileSystemClient.md
Marc Schier e78d36ce4b Client/server transfer, WSS, and hosting fixes (#4802)
# Description

Layer 2 of the upstream non-UaLens extraction, with core/types
prerequisite #4801 (`marcschier-extract-core-and-types`). The initial
extraction commit `86ff10d3994f132f7343a929abdae209bc2e69b7` started
from `8881e096fb5e4eaa36f3c13f8caa57733373bd19` and copied exactly 13
client/server paths from #4462 at frozen head
`390c4a7a51b71da40caec2d22ef74518bbe670ce`, using its original base
`5b03e9afa492cdb36049ee52ae844f630f89d8db`. Those initial blobs were
source-identical; the core/types layer was not duplicated or rewritten.

- Add bounded temporary-file copying with byte/request limits, session
ownership checks, and independent bounded cleanup.
- Refresh revised monitored-item parameters after snapshot transfer and
notify the target handler after successful handle synchronization.
- Apply WSS opening deadlines while preserving caller cancellation, and
cover the wire envelope and request timeout behavior.
- Make hosted server presets honor role configuration without replacing
an explicitly registered role manager; include the corresponding tests
and documentation.

**Review follow-up:** commit `a5c8fe0884ad3346106529deba71d93c5df20a9a`
intentionally corrects three defects in the extracted bounded-copy
implementation. It captures one `SessionRequestContext` for managed
transfers, calls the standard `FileType.Close` declaration without
metadata discovery, and retains valid generation handles before
rejecting non-Good status or malformed metadata. Read/Close remain
strictly validated, and transfer/cleanup failures remain independently
visible. The follow-up also adds 21 direct-client regression cases and
updates `docs/FileSystemClient.md`, bringing this PR to 15 changed
paths. The current bounded-copy implementation is therefore
intentionally no longer identical to the frozen source.

No UaLens projects/UI, AMB or Machinery implementations, samples,
CI/native renderer changes, release changes, or generic infrastructure
files are included. The follow-up does not merge/rebase a lower or upper
stack layer.

**Current review-fix validation:** normal analyzers and
warnings-as-errors remained enabled, using the existing SDK **10.0.401**
without changing `global.json`, installing an SDK, or adding
suppressions. The final file-system and session-context selection passed
**241 tests on net10.0 and 241 tests on net48**, with **0 failed and 0
skipped** on each. Both selections include all 21 new regressions.
Before the production fix, the first 20-case regression suite had 13
failures and 7 passes, reproducing the lost cleanup, cancellation,
session-recreation and Close-discovery defects. An additional
deterministic recovery-gate race case was then added and passed on both
frameworks. Informational-severity formatting/style/analyzer checks
passed for the changed C# files.

| Review requirement | Direct regression evidence |
| --- | --- |
| Pin the managed generation through copying and cleanup |
`CopyRejectsRecreatedManagedGenerationAsync`,
`CopyRejectsRecoveryBetweenGenerationAndReadDispatchAsync` |
| Preserve a generation response that returns after cancellation |
`ManagedCopyClosesHandleReturnedAfterCancellationAsync` with immediate
and delayed responses through a real managed transport lease |
| Do not spend the Close deadline discovering metadata |
`CopyUsesDeclarationCloseWithoutDiscoveryAsync` for ordinary and managed
sessions |
| Retain valid handles before rejecting generation status/metadata |
`CopyClosesHandleBeforeRejectingGenerationStatusAsync`,
`CopyClosesHandleFromMalformedGenerationResponseAsync` |
| Preserve error semantics and strict Read/Close validation |
`CopyRejectsGenerationWithoutInventingHandleAsync`,
`CopyPreservesGenerationAndCleanupFailuresAsync`,
`CopyKeepsReadAndCloseStatusValidationAsync` |

<details>
<summary>Exact review-fix validation commands and results</summary>

Commands ran from this layer's repository root. The following variables
expand the actual executable, filters and results paths. Console logs
and TRX reports were retained in the session artifacts.

```powershell
$dotnet = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe'
$env:DOTNET_ROOT = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk'
$env:DOTNET_ROOT_X64 = $env:DOTNET_ROOT
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
$client = 'tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj'
$filter = 'FullyQualifiedName~Opc.Ua.Client.Tests.FileSystem|FullyQualifiedName~Opc.Ua.Client.Tests.SessionContexts'
$r10 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\feedback-net10'
$r48 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\feedback-net48'

# Before modifying production code, after the new tests compiled:
& $dotnet test $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~TemporaryFileTransferClientBoundedReadTests' --logger 'trx;LogFileName=bounded-copy-feedback-red.trx' --results-directory $r10 --verbosity quiet
# Expected regression failure: Passed: 7, Failed: 13, Skipped: 0.

# After the production fix, before adding the explicit queued-recovery case:
& $dotnet test $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~TemporaryFileTransferClientBoundedReadTests' --logger 'trx;LogFileName=bounded-copy-feedback-green.trx' --results-directory $r10 --verbosity quiet
# Passed: 20, Failed: 0, Skipped: 0.

# Final complete affected selections, including all 21 new cases:
& $dotnet test $client --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter $filter --logger 'trx;LogFileName=file-system-context-feedback-final.trx' --results-directory $r48 --verbosity quiet
# Passed: 241, Failed: 0, Skipped: 0.

& $dotnet test $client -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $filter --logger 'trx;LogFileName=file-system-context-feedback-final.trx' --results-directory $r10 --verbosity quiet
# Passed: 241, Failed: 0, Skipped: 0.

$sourceFile = 'D:\git\copilot-worktrees\UA-wot-d3-final\marcschier-improved-invention\src\Opc.Ua.Client\FileSystem\TemporaryFileTransferClient.BoundedRead.cs'
$testFile = 'D:\git\copilot-worktrees\UA-wot-d3-final\marcschier-improved-invention\tests\Opc.Ua.Client.Tests\FileSystem\TemporaryFileTransferClientBoundedReadTests.cs'
$env:CustomTestTarget = 'net48'
& $dotnet format 'src\Opc.Ua.Client\Opc.Ua.Client.csproj' --include $sourceFile --no-restore --verify-no-changes --severity info --verbosity quiet
# Passed, exit 0.
$env:CustomTestTarget = 'net10.0'
& $dotnet format $client --include $testFile --no-restore --verify-no-changes --severity info --verbosity quiet
# Passed, exit 0.

git diff --check
# Passed. CRLF, final newlines and the 120-column C# limit were also checked.
```

Initial no-restore test attempts after switching frameworks exited
without executing tests and were not counted as passes. Diagnostic
`dotnet build ... --no-restore` runs reported `NETSDK1005` for the
missing framework assets; normal restore was then allowed.
Test-authoring compile errors were corrected before recording the red
run, without suppressions. Intermediate broader selections passed
240/240 on each framework before the queued-recovery case increased the
final count to 241.

</details>

**Initial-extraction validation (commit
`86ff10d3994f132f7343a929abdae209bc2e69b7`):** these results are
historical, not a claim that the broader initial selections were rerun
after the review fix. The server test assembly was built as a dependency
of the client project before its `--no-build` runs.

| Initial targeted scope | net10.0 | net48 |
| --- | --- | --- |
| Client subscriptions and file system | Passed: 546, Failed: 0,
Skipped: 0 | Passed: 542, Failed: 0, Skipped: 0 |
| Hosted roles, identity, composition, fluent APIs and builder coverage
| Passed: 164, Failed: 0, Skipped: 0 | Passed: 164, Failed: 0, Skipped:
0 |
| WebAPI WSS transport | Passed: 37, Failed: 0, Skipped: 0 | Not
supported: the repository excludes this .NET 8+ test project on net48 |
| V2 transfer and snapshot integration | Passed: 6, Failed: 0, Skipped:
0 | Passed: 6, Failed: 0, Skipped: 0 |

The first broader net48 hosting run had **9 startup timeouts and
certificate leaks** (155 passed). A single startup case reproduced the
failure. With only a shorter `NUnit.WorkDirectory`, the isolated case
and then all 164 hosting cases passed, with certificate leak tracking
enabled and no teardown leaks. This is consistent with the fixture's
documented .NET Framework PKI path-length constraint; no source or
diagnostic suppression was changed. Both failed runs and successful
reruns are recorded below.

**Deferred coverage:** the broader domain-level
`MachineryResultTransferTests*.cs` fixtures remain in Machinery layer
#4804. They were not imported or run here. This review follow-up adds
direct-client coverage for the three defects instead. The Machinery
owner has been informed that the old expectations for failed Close
discovery and zero cleanup after a returned non-Good generation response
must be reconciled when the lower fixes are propagated.

**Not run:** complete `UA.slnx` suites on net48/net10.0, a coverage
measurement, CI/CodeQL completion, native/fuzz qualifications, or
package publishing. This remains a draft pending full-stack integration
and CI evidence.

<details>
<summary>Initial-extraction validation command arguments and
outcomes</summary>

All commands ran from this layer's repository root. Variables below
expand the executable, project paths, filters and results directories
used by the actual commands; test output was also retained in
session-local log files.

```powershell
$dotnet = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe'
$env:DOTNET_ROOT = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk'
$env:DOTNET_ROOT_X64 = $env:DOTNET_ROOT
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
$r10 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\validation-net10'
$r48 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\validation-net48'
$client = 'tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj'
$server = 'tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj'
$webapi = 'tests\Opc.Ua.Bindings.Https.WebApi.Tests\Opc.Ua.Bindings.Https.WebApi.Tests.csproj'
$subscriptions = 'tests\Opc.Ua.Subscriptions.Tests\Opc.Ua.Subscriptions.Tests.csproj'
$clientFilter = 'FullyQualifiedName~Opc.Ua.Client.Subscriptions|FullyQualifiedName~Opc.Ua.Client.Tests.FileSystem'
$serverFilter = 'FullyQualifiedName~HostedRolePresetTests|FullyQualifiedName~IdentityHostingTests|FullyQualifiedName~ServerFluentApiHostingTests|FullyQualifiedName~ServerCompositionHostingTests|FullyQualifiedName~OpcUaServerBuilderExtensionsCoverageTests'
$subscriptionFilter = 'FullyQualifiedName~Opc.Ua.Subscriptions.Tests.TransferSubscriptionTests|FullyQualifiedName~Opc.Ua.Subscriptions.Tests.SubscriptionSnapshotTests'

& $dotnet --version
# Passed: 10.0.401.

& $dotnet test $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $clientFilter --logger 'trx;LogFileName=client-net10.trx' --results-directory $r10 --verbosity quiet
# Exit 0 but no tests executed: missing restore metadata. NOT counted as validation.

& $dotnet build $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --verbosity quiet
# Failed: NETSDK1004, missing obj\project.assets.json. Normal restore was then allowed in test commands.

& $dotnet test $client -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $clientFilter --logger 'trx;LogFileName=client-net10.trx' --results-directory $r10 --verbosity quiet
# Passed: 546, Failed: 0, Skipped: 0.

& $dotnet test $server --no-build --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $serverFilter --logger 'trx;LogFileName=server-net10.trx' --results-directory $r10 --verbosity quiet
# Passed: 164, Failed: 0, Skipped: 0.

& $dotnet test $webapi -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~WebApiWssTransportChannelTests' --logger 'trx;LogFileName=webapi-net10.trx' --results-directory $r10 --verbosity quiet
# Passed: 37, Failed: 0, Skipped: 0.

& $dotnet test $client -c Release -f net48 -p:CustomTestTarget=net48 --filter $clientFilter --logger 'trx;LogFileName=client-net48.trx' --results-directory $r48 --verbosity quiet
# Passed: 542, Failed: 0, Skipped: 0.

& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter $serverFilter --logger 'trx;LogFileName=server-net48.trx' --results-directory $r48 --verbosity quiet
# Failed: Passed: 155, Failed: 9, Skipped: 0; startup timeouts and 9 undisposed certificates at teardown.

& $dotnet test $subscriptions -c Release -f net48 -p:CustomTestTarget=net48 --filter $subscriptionFilter --logger 'trx;LogFileName=subscriptions-net48.trx' --results-directory $r48 --verbosity quiet
# Passed: 6, Failed: 0, Skipped: 0.

& $dotnet test $subscriptions -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $subscriptionFilter --logger 'trx;LogFileName=subscriptions-net10.trx' --results-directory $r10 --verbosity quiet
# Passed: 6, Failed: 0, Skipped: 0.

& $dotnet msbuild $webapi -p:CustomTestTarget=net48 -p:TargetFramework=net48 -getProperty:TargetFramework,RestrictForLegacyTfm,_RestrictedToLegacyTfm,IsTestProject,EnableDefaultCompileItems
# Passed: net48 / true / true / false / false. Confirms the net48 project is an empty non-test shell.

# Each following diagnostic invocation had certificate allocation tracking enabled.
$env:OPCUA_CERTIFICATE_LEAK_TRACKING = '1'

& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter 'FullyQualifiedName~HostedRolePresetTests' --logger 'trx;LogFileName=hosted-role-net48.trx' --results-directory $r48 --verbosity quiet
# Passed: 10, Failed: 0, Skipped: 0.

& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter 'FullyQualifiedName~ServerFluentApiHostingTests.AddServerWithCustomServerStartsCustomServerAsync' --logger 'trx;LogFileName=hosting-startup-net48.trx' --results-directory $r48 --verbosity quiet
# Failed: Passed: 0, Failed: 1, Skipped: 0; 30-second startup timeout and one certificate leak.

# A fresh, dedicated short working directory was created for the following two runs.
& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter 'FullyQualifiedName~ServerFluentApiHostingTests.AddServerWithCustomServerStartsCustomServerAsync' --logger 'trx;LogFileName=hosting-startup-net48-shortpath.trx' --results-directory $r48 --verbosity quiet -- 'NUnit.WorkDirectory=D:\Temp\opc-l2-6caf4556'
# Passed: 1, Failed: 0, Skipped: 0; no certificate leak.

& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter $serverFilter --logger 'trx;LogFileName=server-net48-shortpath.trx' --results-directory $r48 --verbosity quiet -- 'NUnit.WorkDirectory=D:\Temp\opc-l2-6caf4556'
# Passed: 164, Failed: 0, Skipped: 0; no certificate leak or assembly-level teardown error.
# The dedicated short working directory and its generated PKI contents were removed afterward.
```

TRX inspection confirmed all initially added client (3), WSS (4), and
hosted-role (10) regression cases executed on supported frameworks. The
initial 13-path set and source blobs were verified before the extraction
commit. A separate mistyped SDK availability probe
(`uals-528-deep-repro` instead of `ualens-528-deep-repro`) failed before
launching any process; all actual builds/tests used the correct verified
SDK above.

</details>

## Related Issues

Related to #4461; extracted from source PR #4462. Neither is closed by
this PR. Prerequisite: #4801, the core/types layer.

_If this is a relatively large or complex change, a design must have
been discussed in the related tracking issue and signed off (which
becomes the Architectural Decision Record (ADR))._

## 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.
- [ ] 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-10-15 19:29:42 +02:00

542 lines
27 KiB
Markdown

# 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
- [Quick reference](#quick-reference)
- [Getting started](#getting-started)
- [Open the standard server file system](#open-the-standard-server-file-system)
- [Open any `FileDirectoryType` instance](#open-any-filedirectorytype-instance)
- [Read a file](#read-a-file)
- [Stream a large file](#stream-a-large-file)
- [Create a directory tree and write a file](#create-a-directory-tree-and-write-a-file)
- [Move and delete](#move-and-delete)
- [Use a file known by its NodeId](#use-a-file-known-by-its-nodeid)
- [Path syntax](#path-syntax)
- [Path resolution and caching](#path-resolution-and-caching)
- [Type classification](#type-classification-filetype-vs-filedirectorytype)
- [File metadata](#file-metadata)
- [Streams](#streams)
- [Error mapping](#error-mapping)
- [Move, copy, and delete semantics](#move-copy-and-delete-semantics)
- [Temporary file transfer](#temporary-file-transfer-part-5-c5)
- [Generation-bound transfers](#generation-bound-transfers)
- [What lives where](#what-lives-where)
- [Server-side file bindings](#server-side-file-bindings)
- [See also](#see-also)
## Getting started
### Open the standard server file system
Many OPC UA servers expose the standard `Server.FileSystem` object
(`NodeId i=16314`):
```csharp
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
```csharp
NodeId vendorRoot = /* result of a Browse / TranslateBrowsePathsToNodeIds */;
var fs = new FileSystemClient(session, vendorRoot);
```
### Read a file
```csharp
string content = await fs.ReadAllTextAsync("/Reports/2024/summary.json");
```
### Stream a large file
```csharp
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
```csharp
UaDirectoryInfo dir = await fs.CreateDirectoryAsync("/Uploads/2024-05/test", createIntermediate: true);
await fs.WriteAllBytesAsync($"{dir.FullPath}/payload.bin", payload);
```
### Move and delete
```csharp
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:
```csharp
// 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:
```csharp
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.
```csharp
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:
```csharp
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`](Sessions.md#generation-bound-client-requests) 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.
```csharp
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](Vision.md) 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.
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](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).