mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
e78d36ce4b
# 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.
542 lines
27 KiB
Markdown
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).
|