From a7ad98bdf6871cfcc7d4b37ebdf1ccb58ce7b4a6 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 10:46:58 +0200 Subject: [PATCH 1/5] Add FileSystemClient: System.IO-style async client over OPC UA file APIs Implements Libraries/Opc.Ua.Client/FileSystem/ on top of the source-generated FileTypeClient, FileDirectoryTypeClient and TemporaryFileTransferTypeClient proxies in Opc.Ua.Core (Part 5 Annex C / Part 20 Section 4). Public surface mirrors System.IO: FileSystemClient (entry point), UaFileSystemInfo + UaFileInfo + UaDirectoryInfo, UaFileStream : System.IO.Stream (async + sync forwarders), UaPath. Separate TemporaryFileTransferClient + UaTemporaryWriteFile for Part 5 Section C.5 with single-terminal-call commit lifecycle. Path syntax is forward-slash only with namespace-aware QualifiedName segments. Path resolution caches resolved (parent, name)->child NodeIds in a small LRU. Type-aware enumeration via Session.TypeTree.IsTypeOf (subtype-aware by default). UaFileStream chunks Read/Write at FileSystemClientOptions.ChunkSize (clamped to MaxByteStringLength), tracks Length/Position locally, pushes SetPosition lazily, issues Close exactly once. DisposeAsync follows the recommended pattern from https://learn.microsoft.com/dotnet/standard/garbage-collection/implementing-disposeasync. Status codes (BadNoMatch, BadNotFound, BadBrowseNameDuplicated, BadUserAccessDenied, BadNotWritable, ...) are translated to FileNotFoundException / DirectoryNotFoundException / UnauthorizedAccessException / IOException at the public boundary. Tests: 111 unit tests under Tests/Opc.Ua.Client.Tests/FileSystem/ covering UaPath (28), PathCache (13), FileSystemErrors (13), UaFileStream (15), FileSystemClientOptions (6), TemporaryFileTransferClient (4), FileSystemClient path resolution (11), enumeration (6), metadata (3), and CRUD (12). All mock-based (Moq + a FileSystemSessionHarness fake address space). Docs/FileSystemClient.md describes the public API, path syntax, error mapping table, recursive-delete semantics, and the temporary-file-transfer flow. 0 errors, 0 warnings on Opc.Ua.Client and Opc.Ua.Client.Tests across all 6 TFMs (net472, net48, netstandard2.1, net8.0, net9.0, net10.0). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Docs/FileSystemClient.md | 281 ++++ .../Opc.Ua.Client/FileSystem/FileMetadata.cs | 50 + .../FileSystem/FileSystemClient.cs | 1357 +++++++++++++++++ .../FileSystem/FileSystemClientOptions.cs | 125 ++ .../FileSystem/FileSystemErrors.cs | 151 ++ .../Opc.Ua.Client/FileSystem/PathCache.cs | 240 +++ .../FileSystem/TemporaryFileTransferClient.cs | 184 +++ .../FileSystem/UaDirectoryInfo.cs | 166 ++ .../Opc.Ua.Client/FileSystem/UaFileInfo.cs | 345 +++++ .../Opc.Ua.Client/FileSystem/UaFileMode.cs | 84 + .../Opc.Ua.Client/FileSystem/UaFileStream.cs | 572 +++++++ .../FileSystem/UaFileSystemInfo.cs | 193 +++ Libraries/Opc.Ua.Client/FileSystem/UaPath.cs | 293 ++++ .../FileSystem/UaTemporaryWriteFile.cs | 307 ++++ .../FileSystem/FileSystemClientCrudTests.cs | 361 +++++ .../FileSystemClientEnumerationTests.cs | 158 ++ .../FileSystemClientMetadataTests.cs | 124 ++ .../FileSystemClientOptionsTests.cs | 104 ++ .../FileSystemClientPathResolutionTests.cs | 220 +++ .../FileSystem/FileSystemErrorsTests.cs | 157 ++ .../FileSystem/FileSystemSessionHarness.cs | 525 +++++++ .../FileSystem/FileTypeSessionMock.cs | 178 +++ .../FileSystem/PathCacheTests.cs | 207 +++ .../TemporaryFileTransferClientTests.cs | 229 +++ .../FileSystem/UaFileStreamTests.cs | 401 +++++ .../FileSystem/UaPathTests.cs | 259 ++++ Tools/Opc.Ua.SourceGeneration/readme.md | 5 + 27 files changed, 7276 insertions(+) create mode 100644 Docs/FileSystemClient.md create mode 100644 Libraries/Opc.Ua.Client/FileSystem/FileMetadata.cs create mode 100644 Libraries/Opc.Ua.Client/FileSystem/FileSystemClient.cs create mode 100644 Libraries/Opc.Ua.Client/FileSystem/FileSystemClientOptions.cs create mode 100644 Libraries/Opc.Ua.Client/FileSystem/FileSystemErrors.cs create mode 100644 Libraries/Opc.Ua.Client/FileSystem/PathCache.cs create mode 100644 Libraries/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.cs create mode 100644 Libraries/Opc.Ua.Client/FileSystem/UaDirectoryInfo.cs create mode 100644 Libraries/Opc.Ua.Client/FileSystem/UaFileInfo.cs create mode 100644 Libraries/Opc.Ua.Client/FileSystem/UaFileMode.cs create mode 100644 Libraries/Opc.Ua.Client/FileSystem/UaFileStream.cs create mode 100644 Libraries/Opc.Ua.Client/FileSystem/UaFileSystemInfo.cs create mode 100644 Libraries/Opc.Ua.Client/FileSystem/UaPath.cs create mode 100644 Libraries/Opc.Ua.Client/FileSystem/UaTemporaryWriteFile.cs create mode 100644 Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientCrudTests.cs create mode 100644 Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientEnumerationTests.cs create mode 100644 Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientMetadataTests.cs create mode 100644 Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientOptionsTests.cs create mode 100644 Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientPathResolutionTests.cs create mode 100644 Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemErrorsTests.cs create mode 100644 Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemSessionHarness.cs create mode 100644 Tests/Opc.Ua.Client.Tests/FileSystem/FileTypeSessionMock.cs create mode 100644 Tests/Opc.Ua.Client.Tests/FileSystem/PathCacheTests.cs create mode 100644 Tests/Opc.Ua.Client.Tests/FileSystem/TemporaryFileTransferClientTests.cs create mode 100644 Tests/Opc.Ua.Client.Tests/FileSystem/UaFileStreamTests.cs create mode 100644 Tests/Opc.Ua.Client.Tests/FileSystem/UaPathTests.cs diff --git a/Docs/FileSystemClient.md b/Docs/FileSystemClient.md new file mode 100644 index 0000000000..3c3974b2b1 --- /dev/null +++ b/Docs/FileSystemClient.md @@ -0,0 +1,281 @@ +# FileSystemClient — a `System.IO`-style async client for OPC UA file systems + +`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 + +| Type | Mirrors | Purpose | +|---|---|---| +| `FileSystemClient` | `System.IO.Directory` + `System.IO.File` | Top-level entry point. Exposes path-based operations (`GetFileAsync`, `CreateDirectoryAsync`, `DeleteAsync`, `MoveAsync`, `CopyAsync`, `ReadAllBytesAsync`, …). Rooted at any `FileDirectoryType` instance. | +| `UaFileSystemInfo` | `System.IO.FileSystemInfo` | Abstract base for `UaFileInfo` and `UaDirectoryInfo`. Carries the resolved `NodeId`, parent reference, browse name, and canonical path. | +| `UaFileInfo` | `System.IO.FileInfo` | A single file. Lazy-loaded `Size` / `Writable` / `MimeType` / etc. metadata; `OpenAsync` / `OpenReadAsync` / `OpenWriteAsync` returning a `UaFileStream`; bulk `ReadAllBytes` / `WriteAllText` shortcuts. | +| `UaDirectoryInfo` | `System.IO.DirectoryInfo` | A single directory. `EnumerateAsync` / `EnumerateFilesAsync` / `EnumerateDirectoriesAsync` (`IAsyncEnumerable<>`); `CreateSubdirectoryAsync`, `CreateFileAsync`, `DeleteAsync(recursive)`. | +| `UaFileStream` | `System.IO.FileStream` | A `Stream`-derived wrapper around an open server file handle. Async members hit the wire via `FileTypeClient`; sync members forward via `GetAwaiter().GetResult()`. | +| `UaPath` | `System.IO.Path` | Helpers for parsing, formatting, combining, and splitting OPC UA file paths. | +| `FileSystemClientOptions` | n/a | Tuning knobs: `ChunkSize`, `MaxBufferedReadSize`, `PathCacheSize`, type-subtype filters. | +| `TemporaryFileTransferClient` + `UaTemporaryWriteFile` | n/a | Separate surface for Part 5 §C.5 atomic temp-file transfers (`GenerateFileForRead/Write` + `CloseAndCommit`). | + +## Getting started + +### Open the standard server file system + +Many OPC UA servers expose the standard `Server.FileSystem` object +(`NodeId i=16314`): + +```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); +``` + +## Path syntax + +Paths use the forward slash `'/'` as the segment separator. Each segment +is parsed by `QualifiedName.Parse(...)`, so the standard +`":"` 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). + +`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: + +| 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. + +## 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](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). diff --git a/Libraries/Opc.Ua.Client/FileSystem/FileMetadata.cs b/Libraries/Opc.Ua.Client/FileSystem/FileMetadata.cs new file mode 100644 index 0000000000..a3b94b4dba --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/FileMetadata.cs @@ -0,0 +1,50 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +namespace Opc.Ua.Client.FileSystem +{ + /// + /// Snapshot of the seven well-known + /// property values populated + /// by . Optional fields are + /// null when the server does not expose the property. + /// + internal readonly struct FileMetadata + { + public ulong? Size { get; init; } + public bool? Writable { get; init; } + public bool? UserWritable { get; init; } + public ushort? OpenCount { get; init; } + public string? MimeType { get; init; } + public uint? MaxByteStringLength { get; init; } + public DateTime? LastModifiedTime { get; init; } + } +} diff --git a/Libraries/Opc.Ua.Client/FileSystem/FileSystemClient.cs b/Libraries/Opc.Ua.Client/FileSystem/FileSystemClient.cs new file mode 100644 index 0000000000..9a1540d44f --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/FileSystemClient.cs @@ -0,0 +1,1357 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Client.FileSystem +{ + /// + /// A System.IO-style asynchronous client over the OPC UA + /// file-system primitives defined in Part 5 §C and Part 20 §4 (the + /// FileType, FileDirectoryType and + /// TemporaryFileTransferType object types). + /// + /// + /// + /// A is rooted at any object of + /// type FileDirectoryType (or a subtype thereof). The + /// helper + /// roots at the standard Server.FileSystem object + /// (NodeId i=16314). Other directories — for example a + /// vendor-specific location reached via Browse — can be passed to + /// the regular constructor. + /// + /// + /// Path syntax is documented on . Briefly: + /// segments are s, separated by + /// forward slashes ('/'), with optional + /// "<ns>:" namespace prefix per segment. The empty + /// string and "/" both mean "the root". + /// + /// + /// All operations are async-only. When a server returns a + /// well-known file-system status code + /// (BadNoMatch, BadNotFound, BadNodeIdUnknown, + /// BadBrowseNameDuplicated, BadUserAccessDenied, …) + /// the failure is translated into the familiar System.IO + /// equivalent (, + /// , + /// , + /// ); other Bad codes propagate as + /// per the existing OPC UA + /// convention. + /// + /// + public sealed class FileSystemClient + { + /// + /// Creates a new rooted at the + /// supplied . + /// + /// The OPC UA session used for all + /// service calls. + /// The NodeId of the + /// FileDirectoryType instance to use as the root. Must + /// not be null. + /// Optional configuration; defaults are + /// applied when null. + public FileSystemClient( + ISession session, + NodeId rootDirectoryId, + FileSystemClientOptions? options = null) + { + Session = session ?? throw new ArgumentNullException(nameof(session)); + if (rootDirectoryId.IsNull) + { + throw new ArgumentNullException(nameof(rootDirectoryId)); + } + Options = (options ?? new FileSystemClientOptions()).Clone(); + Options.Validate(); + m_pathCache = new PathCache(Options.PathCacheSize); + Root = new UaDirectoryInfo(this, parent: null, rootDirectoryId, kRootBrowseName, []); + } + + /// + /// Returns a rooted at the + /// standard OPC UA Server.FileSystem object + /// (NodeId i=16314). Servers that do not expose this + /// object will fail on the first operation with + /// . + /// + public static FileSystemClient OpenServerFileSystem( + ISession session, + FileSystemClientOptions? options = null) + { + return new FileSystemClient( + session, + ObjectIds.FileSystem, + options); + } + + /// The session used for all service calls. + public ISession Session { get; } + + /// The (cloned, immutable) configuration. + public FileSystemClientOptions Options { get; } + + /// The root directory. + public UaDirectoryInfo Root { get; } + + // ---------------------------------------------------------------- + // Public path-based API + // ---------------------------------------------------------------- + + /// + /// Resolves as a directory; throws + /// if missing or if + /// the resolved object is not a directory. + /// + public async ValueTask GetDirectoryAsync( + string path, + CancellationToken ct = default) + { + UaFileSystemInfo? info = await GetInfoAsync(path, ct).ConfigureAwait(false); + if (info is UaDirectoryInfo dir) + { + return dir; + } + throw (Exception)FileSystemErrors.NotFound(path, targetIsDirectory: true); + } + + /// + /// Resolves as a file; throws + /// if missing or if the + /// resolved object is not a file. + /// + public async ValueTask GetFileAsync( + string path, + CancellationToken ct = default) + { + UaFileSystemInfo? info = await GetInfoAsync(path, ct).ConfigureAwait(false); + if (info is UaFileInfo file) + { + return file; + } + throw (Exception)FileSystemErrors.NotFound(path, targetIsDirectory: false); + } + + /// + /// Resolves and returns the matching + /// info object, or null when nothing exists. + /// + public async ValueTask GetInfoAsync( + string path, + CancellationToken ct = default) + { + QualifiedName[] segments = UaPath.Parse(path); + if (segments.Length == 0) + { + return Root; + } + ResolvedNode? resolved = await ResolveSegmentsAsync(segments, throwOnMissing: false, ct) + .ConfigureAwait(false); + if (resolved == null) + { + return null; + } + return await BuildInfoAsync(resolved.Value, segments, ct).ConfigureAwait(false); + } + + /// + /// Returns true when something (file or directory) + /// exists at . + /// + public async ValueTask ExistsAsync(string path, CancellationToken ct = default) + { + return await GetInfoAsync(path, ct).ConfigureAwait(false) != null; + } + + /// + /// Returns true when a file exists at + /// . + /// + public async ValueTask FileExistsAsync(string path, CancellationToken ct = default) + { + UaFileSystemInfo? info = await GetInfoAsync(path, ct).ConfigureAwait(false); + return info is UaFileInfo; + } + + /// + /// Returns true when a directory exists at + /// . + /// + public async ValueTask DirectoryExistsAsync(string path, CancellationToken ct = default) + { + UaFileSystemInfo? info = await GetInfoAsync(path, ct).ConfigureAwait(false); + return info is UaDirectoryInfo; + } + + /// + /// Creates the directory at , including + /// any missing intermediate directories when + /// is true. + /// + /// The target path. + /// When true, missing + /// parent directories are created in turn. + /// Cancellation token. + /// + /// + public async ValueTask CreateDirectoryAsync( + string path, + bool createIntermediate = true, + CancellationToken ct = default) + { + QualifiedName[] segments = UaPath.Parse(path); + if (segments.Length == 0) + { + return Root; + } + UaDirectoryInfo current = Root; + for (int i = 0; i < segments.Length; i++) + { + bool isLast = i == segments.Length - 1; + QualifiedName segment = segments[i]; + NodeId? childId = await TryResolveSingleAsync(current.NodeId, segment, ct) + .ConfigureAwait(false); + if (childId != null) + { + UaFileSystemInfo info = await BuildInfoAsync( + new ResolvedNode(childId.Value, segment), + segments.Take(i + 1).ToArray(), + ct).ConfigureAwait(false); + if (info is UaDirectoryInfo dir) + { + current = dir; + continue; + } + throw new IOException( + $"Cannot create directory '{path}': '{string.Join("/", segments.Take(i + 1).Select(UaPath.FormatSegment))}' is not a directory."); + } + if (!createIntermediate && !isLast) + { + throw (Exception)FileSystemErrors.NotFound( + UaPath.Format(segments.Take(i + 1).ToArray()), + targetIsDirectory: true); + } + if (segment.NamespaceIndex != 0) + { + throw new ArgumentException( + $"Cannot create '{UaPath.FormatSegment(segment)}': leaf segments must not include a namespace prefix; the server picks the BrowseName namespace.", + nameof(path)); + } + current = await CreateDirectoryInAsync(current, segment.Name, ct) + .ConfigureAwait(false); + } + return current; + } + + /// + /// Creates the file at , optionally + /// creating any missing intermediate directories. The server + /// is asked NOT to immediately open the file + /// (requestFileOpen: false). + /// + /// + public async ValueTask CreateFileAsync( + string path, + bool createIntermediate = true, + CancellationToken ct = default) + { + QualifiedName[] segments = UaPath.Parse(path); + if (segments.Length == 0) + { + throw new ArgumentException("Cannot create a file at the root path.", nameof(path)); + } + UaDirectoryInfo parent; + if (segments.Length == 1) + { + parent = Root; + } + else + { + string parentPath = UaPath.Format(segments.Take(segments.Length - 1).ToArray()); + if (createIntermediate) + { + parent = await CreateDirectoryAsync(parentPath, true, ct).ConfigureAwait(false); + } + else + { + parent = await GetDirectoryAsync(parentPath, ct).ConfigureAwait(false); + } + } + QualifiedName leaf = segments[^1]; + if (leaf.NamespaceIndex != 0) + { + throw new ArgumentException( + $"Cannot create file '{UaPath.FormatSegment(leaf)}': leaf segments must not include a namespace prefix; the server picks the BrowseName namespace.", + nameof(path)); + } + return await CreateFileInAsync(parent, leaf.Name, ct).ConfigureAwait(false); + } + + /// + /// Deletes the file or directory at . + /// When is true and the + /// target is a directory, the server's recursive + /// Delete primitive is invoked (no client-side + /// traversal). + /// + public async ValueTask DeleteAsync( + string path, + bool recursive = false, + CancellationToken ct = default) + { + UaFileSystemInfo? info = await GetInfoAsync(path, ct).ConfigureAwait(false); + if (info == null) + { + throw (Exception)FileSystemErrors.NotFound(path, targetIsDirectory: false); + } + await DeleteCoreAsync(info, recursive, ct).ConfigureAwait(false); + } + + /// + /// Moves a file or directory from + /// to . + /// + public async ValueTask MoveAsync( + string srcPath, + string destPath, + CancellationToken ct = default) + { + UaFileSystemInfo? src = await GetInfoAsync(srcPath, ct).ConfigureAwait(false); + if (src == null) + { + throw (Exception)FileSystemErrors.NotFound(srcPath, targetIsDirectory: false); + } + return await MoveOrCopyAsync(src, destPath, copy: false, ct).ConfigureAwait(false); + } + + /// + /// Copies a file or directory from + /// to . + /// + public async ValueTask CopyAsync( + string srcPath, + string destPath, + CancellationToken ct = default) + { + UaFileSystemInfo? src = await GetInfoAsync(srcPath, ct).ConfigureAwait(false); + if (src == null) + { + throw (Exception)FileSystemErrors.NotFound(srcPath, targetIsDirectory: false); + } + return await MoveOrCopyAsync(src, destPath, copy: true, ct).ConfigureAwait(false); + } + + /// + /// Opens the file at for reading. + /// + public async ValueTask OpenReadAsync( + string path, + CancellationToken ct = default) + { + UaFileInfo file = await GetFileAsync(path, ct).ConfigureAwait(false); + await file.RefreshAsync(ct).ConfigureAwait(false); + return await file.OpenReadAsync(ct).ConfigureAwait(false); + } + + /// + /// Opens the file at for writing + /// (truncates). + /// + public async ValueTask OpenWriteAsync( + string path, + CancellationToken ct = default) + { + UaFileInfo file = await GetFileAsync(path, ct).ConfigureAwait(false); + await file.RefreshAsync(ct).ConfigureAwait(false); + return await file.OpenWriteAsync(ct).ConfigureAwait(false); + } + + /// + /// Opens the file at for appending. + /// + public async ValueTask OpenAppendAsync( + string path, + CancellationToken ct = default) + { + UaFileInfo file = await GetFileAsync(path, ct).ConfigureAwait(false); + await file.RefreshAsync(ct).ConfigureAwait(false); + return await file.OpenAppendAsync(ct).ConfigureAwait(false); + } + + /// + /// Opens the file at with the supplied + /// . + /// + public async ValueTask OpenAsync( + string path, + UaFileMode mode, + CancellationToken ct = default) + { + UaFileInfo file = await GetFileAsync(path, ct).ConfigureAwait(false); + await file.RefreshAsync(ct).ConfigureAwait(false); + return await file.OpenAsync(mode, ct).ConfigureAwait(false); + } + + /// + /// Reads the entire file at into + /// memory. + /// + public async ValueTask ReadAllBytesAsync( + string path, + CancellationToken ct = default) + { + UaFileInfo file = await GetFileAsync(path, ct).ConfigureAwait(false); + await file.RefreshAsync(ct).ConfigureAwait(false); + return await file.ReadAllBytesAsync(ct).ConfigureAwait(false); + } + + /// + /// Reads the entire file at as text. + /// + public async ValueTask ReadAllTextAsync( + string path, + Encoding? encoding = null, + CancellationToken ct = default) + { + UaFileInfo file = await GetFileAsync(path, ct).ConfigureAwait(false); + await file.RefreshAsync(ct).ConfigureAwait(false); + return await file.ReadAllTextAsync(encoding, ct).ConfigureAwait(false); + } + + /// + /// Truncates and overwrites the file at + /// with . Creates the file (and any + /// missing intermediate directories) when it does not exist. + /// + public async ValueTask WriteAllBytesAsync( + string path, + ReadOnlyMemory bytes, + CancellationToken ct = default) + { + UaFileInfo file = await GetOrCreateFileAsync(path, ct).ConfigureAwait(false); + await file.WriteAllBytesAsync(bytes, ct).ConfigureAwait(false); + } + + /// + /// Truncates and overwrites the file at + /// with . Creates the file (and any + /// missing intermediate directories) when it does not exist. + /// + public async ValueTask WriteAllTextAsync( + string path, + string contents, + Encoding? encoding = null, + CancellationToken ct = default) + { + UaFileInfo file = await GetOrCreateFileAsync(path, ct).ConfigureAwait(false); + await file.WriteAllTextAsync(contents, encoding, ct).ConfigureAwait(false); + } + + /// + /// Enumerates the immediate children of the directory at + /// . + /// + public async IAsyncEnumerable EnumerateAsync( + string path = UaPath.Root, + [EnumeratorCancellation] CancellationToken ct = default) + { + UaDirectoryInfo dir = await GetDirectoryAsync(path, ct).ConfigureAwait(false); + await foreach (UaFileSystemInfo child in dir.EnumerateAsync(ct).ConfigureAwait(false)) + { + yield return child; + } + } + + /// + /// Enumerates the immediate file children of the directory at + /// . + /// + public async IAsyncEnumerable EnumerateFilesAsync( + string path = UaPath.Root, + [EnumeratorCancellation] CancellationToken ct = default) + { + UaDirectoryInfo dir = await GetDirectoryAsync(path, ct).ConfigureAwait(false); + await foreach (UaFileInfo child in dir.EnumerateFilesAsync(ct).ConfigureAwait(false)) + { + yield return child; + } + } + + /// + /// Enumerates the immediate directory children of the + /// directory at . + /// + public async IAsyncEnumerable EnumerateDirectoriesAsync( + string path = UaPath.Root, + [EnumeratorCancellation] CancellationToken ct = default) + { + UaDirectoryInfo dir = await GetDirectoryAsync(path, ct).ConfigureAwait(false); + await foreach (UaDirectoryInfo child in dir.EnumerateDirectoriesAsync(ct) + .ConfigureAwait(false)) + { + yield return child; + } + } + + // ---------------------------------------------------------------- + // Internal helpers (called from UaFileSystemInfo / Ua*Info) + // ---------------------------------------------------------------- + + internal async IAsyncEnumerable EnumerateChildrenAsync( + UaDirectoryInfo directory, + bool includeFiles, + bool includeDirectories, + [EnumeratorCancellation] CancellationToken ct = default) + { + await EnsureTypeTreeFetchedAsync(ct).ConfigureAwait(false); + ITypeTable typeTree = Session.TypeTree; + + ByteString continuation; + ArrayOf references; + (_, continuation, references) = await Session.BrowseAsync( + requestHeader: null, + view: null, + directory.NodeId, + maxResultsToReturn: 0, + BrowseDirection.Forward, + ReferenceTypeIds.HierarchicalReferences, + includeSubtypes: true, + (uint)NodeClass.Object, + ct).ConfigureAwait(false); + + while (true) + { + // Materialise to an array first — ReadOnlySpan.Enumerator + // (returned by ArrayOf.GetEnumerator) cannot cross an + // async iterator's `yield return` boundary. + var snapshot = new ReferenceDescription[references.Count]; + for (int i = 0; i < references.Count; i++) + { + snapshot[i] = references[i]; + } + foreach (ReferenceDescription reference in snapshot) + { + UaFileSystemInfo? info = TryClassifyChild( + directory, + reference, + typeTree, + includeFiles, + includeDirectories); + if (info != null) + { + yield return info; + } + } + if (continuation.IsNull) + { + yield break; + } + (_, continuation, references) = await Session.BrowseNextAsync( + requestHeader: null, + releaseContinuationPoint: false, + continuation, + ct).ConfigureAwait(false); + } + } + + internal async ValueTask CreateDirectoryInAsync( + UaDirectoryInfo parent, + string name, + CancellationToken ct) + { + ValidateLeafName(name); + NodeId newId; + try + { + newId = await parent.Proxy.CreateDirectoryAsync(name, ct).ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + throw FileSystemErrors.Translate(ex, parent.FullPath + "/" + name, targetIsDirectory: true); + } + QualifiedName actualName = await ReadBrowseNameAsync(newId, ct).ConfigureAwait(false); + m_pathCache.InvalidateChildrenOf(parent.NodeId); + m_pathCache.Put(parent.NodeId, actualName, newId); + return new UaDirectoryInfo( + this, + parent, + newId, + actualName, + AppendSegment(parent.Segments, actualName)); + } + + internal async ValueTask CreateFileInAsync( + UaDirectoryInfo parent, + string name, + CancellationToken ct) + { + ValidateLeafName(name); + NodeId newId; + try + { + (newId, _) = await parent.Proxy + .CreateFileAsync(name, requestFileOpen: false, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + throw FileSystemErrors.Translate(ex, parent.FullPath + "/" + name, targetIsDirectory: false); + } + QualifiedName actualName = await ReadBrowseNameAsync(newId, ct).ConfigureAwait(false); + m_pathCache.InvalidateChildrenOf(parent.NodeId); + m_pathCache.Put(parent.NodeId, actualName, newId); + return new UaFileInfo( + this, + parent, + newId, + actualName, + AppendSegment(parent.Segments, actualName)); + } + + internal async ValueTask DeleteCoreAsync( + UaFileSystemInfo target, + bool recursive, + CancellationToken ct) + { + if (target.Parent == null) + { + throw new IOException("Cannot delete the root directory."); + } + + if (target is UaDirectoryInfo dir && !recursive) + { + // Empty-check before delegating to server. + await foreach (UaFileSystemInfo _ in dir.EnumerateAsync(ct).ConfigureAwait(false)) + { + throw new IOException( + $"Directory '{target.FullPath}' is not empty; pass recursive: true to delete recursively."); + } + } + + try + { + await target.Parent.Proxy + .DeleteFileSystemObjectAsync(target.NodeId, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + throw FileSystemErrors.Translate(ex, target.FullPath, target.IsDirectory); + } + + m_pathCache.InvalidateChildrenOf(target.Parent.NodeId); + if (target is UaDirectoryInfo) + { + m_pathCache.InvalidateChildrenOf(target.NodeId); + } + } + + internal async ValueTask MoveOrCopyAsync( + UaFileSystemInfo source, + string destPath, + bool copy, + CancellationToken ct) + { + QualifiedName[] segments = UaPath.Parse(destPath); + if (segments.Length == 0) + { + throw new ArgumentException( + "Destination path must include at least one segment.", + nameof(destPath)); + } + UaDirectoryInfo destDir; + if (segments.Length == 1) + { + destDir = Root; + } + else + { + string parentPath = UaPath.Format(segments.Take(segments.Length - 1).ToArray()); + destDir = await GetDirectoryAsync(parentPath, ct).ConfigureAwait(false); + } + return await MoveOrCopyAsync(source, destDir, segments[^1].Name, copy, ct) + .ConfigureAwait(false); + } + + internal async ValueTask MoveOrCopyAsync( + UaFileSystemInfo source, + UaDirectoryInfo destinationDirectory, + string newName, + bool copy, + CancellationToken ct) + { + ValidateLeafName(newName); + if (source.Parent == null) + { + throw new IOException("Cannot move or copy the root directory."); + } + + NodeId newId; + try + { + newId = await source.Parent.Proxy.MoveOrCopyAsync( + source.NodeId, + destinationDirectory.NodeId, + createCopy: copy, + newName, + ct).ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + throw FileSystemErrors.Translate(ex, source.FullPath, source.IsDirectory); + } + + // Invalidate caches for both ends. + m_pathCache.InvalidateChildrenOf(source.Parent.NodeId); + m_pathCache.InvalidateChildrenOf(destinationDirectory.NodeId); + + QualifiedName actualName = await ReadBrowseNameAsync(newId, ct).ConfigureAwait(false); + IReadOnlyList destSegments = AppendSegment( + destinationDirectory.Segments, actualName); + + if (source is UaDirectoryInfo) + { + return new UaDirectoryInfo( + this, destinationDirectory, newId, actualName, destSegments); + } + return new UaFileInfo( + this, destinationDirectory, newId, actualName, destSegments); + } + + internal async ValueTask ReadFileMetadataAsync( + NodeId fileNodeId, + string? path, + CancellationToken ct) + { + // Build a single TranslateBrowsePathsToNodeIds call for the + // seven well-known property browse names. + var browsePathsList = new List(kFileTypePropertyNames.Length); + for (int i = 0; i < kFileTypePropertyNames.Length; i++) + { + var element = new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IsInverse = false, + IncludeSubtypes = true, + TargetName = new QualifiedName(kFileTypePropertyNames[i]) + }; + browsePathsList.Add(new BrowsePath + { + StartingNode = fileNodeId, + RelativePath = new RelativePath { Elements = [element] } + }); + } + ArrayOf browsePaths = browsePathsList.ToArrayOf(); + + TranslateBrowsePathsToNodeIdsResponse response = await Session + .TranslateBrowsePathsToNodeIdsAsync(null, browsePaths, ct) + .ConfigureAwait(false); + ClientBase.ValidateResponse(response.Results, browsePaths); + ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, browsePaths); + + // Collect resolved property NodeIds. + var nodesToReadList = new List(kFileTypePropertyNames.Length); + var indexToProperty = new int[kFileTypePropertyNames.Length]; + for (int i = 0; i < kFileTypePropertyNames.Length; i++) + { + BrowsePathResult result = response.Results[i]; + bool optional = i >= kMandatoryPropertyCount; + + bool empty = result.Targets.Count == 0; + if (StatusCode.IsBad(result.StatusCode) || empty) + { + uint code = result.StatusCode.Code; + if (optional || + code == StatusCodes.BadNoMatch || + code == StatusCodes.BadNodeIdUnknown) + { + continue; + } + throw FileSystemErrors.Translate( + new ServiceResultException(result.StatusCode), + path, + targetIsDirectory: false); + } + + NodeId nodeId = ExpandedNodeId.ToNodeId( + result.Targets[0].TargetId, + Session.MessageContext.NamespaceUris); + indexToProperty[nodesToReadList.Count] = i; + nodesToReadList.Add(new ReadValueId + { + NodeId = nodeId, + AttributeId = Attributes.Value + }); + } + + if (nodesToReadList.Count == 0) + { + return default; + } + + ArrayOf nodesToRead = nodesToReadList.ToArrayOf(); + ReadResponse readResponse = await Session.ReadAsync( + null, + 0.0, + TimestampsToReturn.Neither, + nodesToRead, + ct).ConfigureAwait(false); + ClientBase.ValidateResponse(readResponse.Results, nodesToRead); + ClientBase.ValidateDiagnosticInfos(readResponse.DiagnosticInfos, nodesToRead); + + ulong? size = null; + bool? writable = null; + bool? userWritable = null; + ushort? openCount = null; + string? mimeType = null; + uint? maxByteStringLength = null; + DateTime? lastModifiedTime = null; + + for (int j = 0; j < nodesToRead.Count; j++) + { + int property = indexToProperty[j]; + DataValue dv = readResponse.Results[j]; + if (StatusCode.IsBad(dv.StatusCode)) + { + bool optional = property >= kMandatoryPropertyCount; + uint code = dv.StatusCode.Code; + if (optional || + code == StatusCodes.BadNoMatch || + code == StatusCodes.BadNodeIdUnknown) + { + continue; + } + throw FileSystemErrors.Translate( + new ServiceResultException(dv.StatusCode), + path, + targetIsDirectory: false); + } + + Variant value = dv.WrappedValue; + switch (property) + { + case 0: + size = TryGetUInt64(value); + break; + case 1: + writable = TryGetBool(value); + break; + case 2: + userWritable = TryGetBool(value); + break; + case 3: + openCount = TryGetUInt16(value); + break; + case 4: + mimeType = TryGetString(value); + break; + case 5: + maxByteStringLength = TryGetUInt32(value); + break; + case 6: + lastModifiedTime = TryGetDateTime(value); + break; + } + } + + return new FileMetadata + { + Size = size, + Writable = writable, + UserWritable = userWritable, + OpenCount = openCount, + MimeType = mimeType, + MaxByteStringLength = maxByteStringLength, + LastModifiedTime = lastModifiedTime + }; + } + + // ---------------------------------------------------------------- + // Internals + // ---------------------------------------------------------------- + + private async ValueTask GetOrCreateFileAsync(string path, CancellationToken ct) + { + UaFileSystemInfo? existing = await GetInfoAsync(path, ct).ConfigureAwait(false); + if (existing is UaFileInfo file) + { + return file; + } + if (existing is UaDirectoryInfo) + { + throw new IOException( + $"Cannot write to '{path}': path refers to a directory."); + } + return await CreateFileAsync(path, true, ct).ConfigureAwait(false); + } + + private async ValueTask ResolveSegmentsAsync( + QualifiedName[] segments, + bool throwOnMissing, + CancellationToken ct) + { + // Walk segment-by-segment, leveraging the path cache when + // possible. A single TranslateBrowsePathsToNodeIds call would + // be more efficient on a cache miss, but per-segment walking + // is simpler, lets us populate the cache, and gives precise + // error reporting (we know which segment failed). For most + // workloads the cache covers the prefix so we still avoid + // round-trips. + NodeId currentParent = Root.NodeId; + for (int i = 0; i < segments.Length; i++) + { + bool isLast = i == segments.Length - 1; + QualifiedName segment = segments[i]; + NodeId? cached = m_pathCache.TryGet(currentParent, segment); + if (cached != null) + { + if (isLast) + { + return new ResolvedNode(cached.Value, segment); + } + currentParent = cached.Value; + continue; + } + NodeId? resolved = await TryResolveSingleAsync(currentParent, segment, ct) + .ConfigureAwait(false); + if (resolved == null) + { + if (throwOnMissing) + { + throw (Exception)FileSystemErrors.NotFound( + UaPath.Format(segments.Take(i + 1).ToArray()), + targetIsDirectory: !isLast); + } + return null; + } + m_pathCache.Put(currentParent, segment, resolved.Value); + if (isLast) + { + return new ResolvedNode(resolved.Value, segment); + } + currentParent = resolved.Value; + } + // segments.Length == 0 case is handled by callers. + return null; + } + + private async ValueTask TryResolveSingleAsync( + NodeId parent, + QualifiedName segment, + CancellationToken ct) + { + var element = new RelativePathElement + { + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IsInverse = false, + IncludeSubtypes = true, + TargetName = segment + }; + ArrayOf browsePaths = new[] + { + new BrowsePath + { + StartingNode = parent, + RelativePath = new RelativePath { Elements = [element] } + } + }.ToArrayOf(); + + TranslateBrowsePathsToNodeIdsResponse response = await Session + .TranslateBrowsePathsToNodeIdsAsync(null, browsePaths, ct) + .ConfigureAwait(false); + ClientBase.ValidateResponse(response.Results, browsePaths); + ClientBase.ValidateDiagnosticInfos(response.DiagnosticInfos, browsePaths); + + BrowsePathResult result = response.Results[0]; + uint code = result.StatusCode.Code; + if (code == StatusCodes.BadNoMatch || + code == StatusCodes.BadNodeIdUnknown) + { + return null; + } + if (StatusCode.IsBad(result.StatusCode)) + { + throw new ServiceResultException(result.StatusCode); + } + ArrayOf targets = result.Targets; + if (targets.Count == 0) + { + return null; + } + if (targets.Count > 1) + { + throw FileSystemErrors.Ambiguous(UaPath.FormatSegment(segment), targets.Count); + } + return ExpandedNodeId.ToNodeId( + targets[0].TargetId, + Session.MessageContext.NamespaceUris); + } + + private async ValueTask BuildInfoAsync( + ResolvedNode resolved, + QualifiedName[] segments, + CancellationToken ct) + { + NodeId typeDef = await ReadTypeDefinitionAsync(resolved.NodeId, ct).ConfigureAwait(false); + UaDirectoryInfo? parentInfo = null; + if (segments.Length > 1) + { + ResolvedNode? parent = await ResolveSegmentsAsync( + segments.Take(segments.Length - 1).ToArray(), + throwOnMissing: true, + ct).ConfigureAwait(false); + if (parent != null) + { + parentInfo = new UaDirectoryInfo( + this, + parent: null, // grandparent reference omitted for the synthesized parent stub + parent.Value.NodeId, + parent.Value.BrowseName, + segments.Take(segments.Length - 1).ToArray()); + } + } + else + { + parentInfo = Root; + } + + await EnsureTypeTreeFetchedAsync(ct).ConfigureAwait(false); + ITypeTable typeTree = Session.TypeTree; + + bool isFile = IsFileType(typeDef, typeTree); + bool isDir = IsDirectoryType(typeDef, typeTree); + + if (isDir) + { + return new UaDirectoryInfo(this, parentInfo, resolved.NodeId, resolved.BrowseName, segments); + } + if (isFile) + { + return new UaFileInfo(this, parentInfo, resolved.NodeId, resolved.BrowseName, segments); + } + throw new IOException( + $"Path '/{string.Join("/", segments.Select(UaPath.FormatSegment))}' resolves to a NodeId whose TypeDefinition is neither FileType nor FileDirectoryType."); + } + + private UaFileSystemInfo? TryClassifyChild( + UaDirectoryInfo parent, + ReferenceDescription reference, + ITypeTable typeTree, + bool includeFiles, + bool includeDirectories) + { + NodeId childId = ExpandedNodeId.ToNodeId( + reference.NodeId, + Session.MessageContext.NamespaceUris); + NodeId typeDef = ExpandedNodeId.ToNodeId( + reference.TypeDefinition, + Session.MessageContext.NamespaceUris); + QualifiedName name = reference.BrowseName; + + bool isDir = IsDirectoryType(typeDef, typeTree); + bool isFile = IsFileType(typeDef, typeTree); + + if (isDir && includeDirectories) + { + m_pathCache.Put(parent.NodeId, name, childId); + return new UaDirectoryInfo( + this, parent, childId, name, AppendSegment(parent.Segments, name)); + } + if (isFile && includeFiles) + { + m_pathCache.Put(parent.NodeId, name, childId); + return new UaFileInfo( + this, parent, childId, name, AppendSegment(parent.Segments, name)); + } + return null; + } + + private bool IsFileType(NodeId typeDef, ITypeTable typeTree) + { + if (typeDef.IsNull) + { + return false; + } + if (typeDef.Equals(kFileTypeId)) + { + return true; + } + return Options.IncludeFileTypeSubtypes && typeTree.IsTypeOf(typeDef, kFileTypeId); + } + + private bool IsDirectoryType(NodeId typeDef, ITypeTable typeTree) + { + if (typeDef.IsNull) + { + return false; + } + if (typeDef.Equals(kFileDirectoryTypeId)) + { + return true; + } + return Options.IncludeFileDirectoryTypeSubtypes && + typeTree.IsTypeOf(typeDef, kFileDirectoryTypeId); + } + + private async ValueTask EnsureTypeTreeFetchedAsync(CancellationToken ct) + { + if (m_typeTreeFetched) + { + return; + } + await Session.FetchTypeTreeAsync(kFileTypeId, ct).ConfigureAwait(false); + await Session.FetchTypeTreeAsync(kFileDirectoryTypeId, ct).ConfigureAwait(false); + m_typeTreeFetched = true; + } + + private async ValueTask ReadTypeDefinitionAsync(NodeId nodeId, CancellationToken ct) + { + (_, _, ArrayOf references) = await Session.BrowseAsync( + requestHeader: null, + view: null, + nodeId, + maxResultsToReturn: 1, + BrowseDirection.Forward, + ReferenceTypeIds.HasTypeDefinition, + includeSubtypes: false, + (uint)NodeClass.ObjectType, + ct).ConfigureAwait(false); + + if (references.Count == 0) + { + return NodeId.Null; + } + return ExpandedNodeId.ToNodeId( + references[0].NodeId, + Session.MessageContext.NamespaceUris); + } + + private async ValueTask ReadBrowseNameAsync(NodeId nodeId, CancellationToken ct) + { + ArrayOf nodesToRead = new[] + { + new ReadValueId + { + NodeId = nodeId, + AttributeId = Attributes.BrowseName + } + }.ToArrayOf(); + ReadResponse response = await Session.ReadAsync( + null, + 0.0, + TimestampsToReturn.Neither, + nodesToRead, + ct).ConfigureAwait(false); + ClientBase.ValidateResponse(response.Results, nodesToRead); + DataValue dv = response.Results[0]; + if (StatusCode.IsBad(dv.StatusCode)) + { + throw new ServiceResultException(dv.StatusCode); + } + return TryGetQualifiedName(dv.WrappedValue) ?? QualifiedName.Null; + } + + private static QualifiedName[] AppendSegment( + IReadOnlyList parent, + QualifiedName name) + { + var combined = new QualifiedName[parent.Count + 1]; + for (int i = 0; i < parent.Count; i++) + { + combined[i] = parent[i]; + } + combined[^1] = name; + return combined; + } + + private static void ValidateLeafName(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("Name must be non-empty.", nameof(name)); + } + if (name.Contains(UaPath.Separator, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Leaf name '{name}' must not contain a path separator.", + nameof(name)); + } + if (name.Contains(':', StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Leaf name '{name}' must not include a namespace prefix; the server picks the BrowseName namespace.", + nameof(name)); + } + } + + private static ulong? TryGetUInt64(Variant value) + { + if (value.TryGetValue(out ulong u64)) + { + return u64; + } + if (value.TryGetValue(out long i64) && i64 >= 0) + { + return (ulong)i64; + } + if (value.TryGetValue(out uint u32)) + { + return u32; + } + if (value.TryGetValue(out int i32) && i32 >= 0) + { + return (ulong)i32; + } + return null; + } + + private static uint? TryGetUInt32(Variant value) + { + if (value.TryGetValue(out uint u32)) + { + return u32; + } + if (value.TryGetValue(out int i32) && i32 >= 0) + { + return (uint)i32; + } + if (value.TryGetValue(out ulong u64) && u64 <= uint.MaxValue) + { + return (uint)u64; + } + return null; + } + + private static ushort? TryGetUInt16(Variant value) + { + if (value.TryGetValue(out ushort u16)) + { + return u16; + } + if (value.TryGetValue(out short i16) && i16 >= 0) + { + return (ushort)i16; + } + if (value.TryGetValue(out uint u32) && u32 <= ushort.MaxValue) + { + return (ushort)u32; + } + if (value.TryGetValue(out int i32) && i32 >= 0 && i32 <= ushort.MaxValue) + { + return (ushort)i32; + } + return null; + } + + private static bool? TryGetBool(Variant value) + { + if (value.TryGetValue(out bool b)) + { + return b; + } + return null; + } + + private static string? TryGetString(Variant value) + { + if (value.TryGetValue(out string s)) + { + return s; + } + return null; + } + + private static DateTime? TryGetDateTime(Variant value) + { + if (value.TryGetValue(out DateTimeUtc dtu)) + { + return dtu.ToDateTime(); + } + return null; + } + + private static QualifiedName? TryGetQualifiedName(Variant value) + { + // QualifiedName is a struct; cast through Raw which preserves + // boxed value types unchanged. + object? raw = value.AsBoxedObject(); + if (raw is QualifiedName qn) + { + return qn; + } + return null; + } + + private readonly struct ResolvedNode + { + public ResolvedNode(NodeId nodeId, QualifiedName browseName) + { + NodeId = nodeId; + BrowseName = browseName; + } + + public NodeId NodeId { get; } + public QualifiedName BrowseName { get; } + } + + /// + /// Indices 0..3 are mandatory in the FileType definition; 4..6 are optional. + /// + private const int kMandatoryPropertyCount = 4; + + private static readonly string[] kFileTypePropertyNames = + [ + "Size", + "Writable", + "UserWritable", + "OpenCount", + "MimeType", + "MaxByteStringLength", + "LastModifiedTime" + ]; + + private static readonly NodeId kFileTypeId = ObjectTypeIds.FileType; + private static readonly NodeId kFileDirectoryTypeId = ObjectTypeIds.FileDirectoryType; + private static readonly QualifiedName kRootBrowseName = new("FileSystem"); + + private readonly PathCache m_pathCache; + private bool m_typeTreeFetched; + } +} diff --git a/Libraries/Opc.Ua.Client/FileSystem/FileSystemClientOptions.cs b/Libraries/Opc.Ua.Client/FileSystem/FileSystemClientOptions.cs new file mode 100644 index 0000000000..d25f7fde71 --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/FileSystemClientOptions.cs @@ -0,0 +1,125 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +namespace Opc.Ua.Client.FileSystem +{ + /// + /// Configuration knobs for FileSystemClient, + /// UaFileStream and TemporaryFileTransferClient. + /// + public sealed class FileSystemClientOptions + { + /// + /// Default chunk size (in bytes) used by + /// UaFileStream.ReadAsync / + /// UaFileStream.WriteAsync when streaming to or from the + /// server. Honours the per-file + /// MaxByteStringLength property when known (clamped down + /// at open time). Default: 64 KiB. + /// + public int ChunkSize { get; set; } = 64 * 1024; + + /// + /// Cap on the number of bytes a single + /// ReadAllBytesAsync / ReadAllTextAsync call may + /// load into memory. Reads that would exceed this size throw a + /// with + /// . Default: + /// 16 MiB. + /// + public long MaxBufferedReadSize { get; set; } = 16 * 1024 * 1024; + + /// + /// Maximum number of resolved + /// (parent NodeId, browse name) → child NodeId entries + /// cached on the client. Set to zero to disable caching. + /// Default: 1024. + /// + public int PathCacheSize { get; set; } = 1024; + + /// + /// When listing directory children, controls whether subtypes of + /// FileType (e.g. TrustListType, + /// AddressSpaceFileType) count as files. Default + /// true. + /// + public bool IncludeFileTypeSubtypes { get; set; } = true; + + /// + /// When listing directory children, controls whether subtypes of + /// FileDirectoryType count as directories. Default + /// true. + /// + public bool IncludeFileDirectoryTypeSubtypes { get; set; } = true; + + /// + /// Returns a deep copy of these options. + /// + /// A new with the + /// same values. + public FileSystemClientOptions Clone() + { + return new FileSystemClientOptions + { + ChunkSize = ChunkSize, + MaxBufferedReadSize = MaxBufferedReadSize, + PathCacheSize = PathCacheSize, + IncludeFileTypeSubtypes = IncludeFileTypeSubtypes, + IncludeFileDirectoryTypeSubtypes = IncludeFileDirectoryTypeSubtypes + }; + } + + internal void Validate() + { + if (ChunkSize <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(ChunkSize), + ChunkSize, + "ChunkSize must be positive."); + } + if (MaxBufferedReadSize <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(MaxBufferedReadSize), + MaxBufferedReadSize, + "MaxBufferedReadSize must be positive."); + } + if (PathCacheSize < 0) + { + throw new ArgumentOutOfRangeException( + nameof(PathCacheSize), + PathCacheSize, + "PathCacheSize must be non-negative."); + } + } + } +} diff --git a/Libraries/Opc.Ua.Client/FileSystem/FileSystemErrors.cs b/Libraries/Opc.Ua.Client/FileSystem/FileSystemErrors.cs new file mode 100644 index 0000000000..93469695c5 --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/FileSystemErrors.cs @@ -0,0 +1,151 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.IO; + +namespace Opc.Ua.Client.FileSystem +{ + /// + /// Centralised mapping of OPC UA values to + /// the -style exceptions surfaced from the + /// public FileSystemClient API. + /// + internal static class FileSystemErrors + { + /// + /// Maps a reported during a + /// file-system operation into a more idiomatic + /// family exception when the status + /// code matches a well-known OPC UA file-system failure mode. + /// Returns unchanged for unrecognised + /// codes so callers may rethrow. + /// + /// The original exception. + /// The path the caller used; included in the + /// message and as the + /// when applicable. + /// When the caller was operating + /// on a directory true selects + /// over + /// . + /// A more specific exception, or + /// when no mapping applies. + /// is null. + public static Exception Translate( + ServiceResultException ex, + string? path, + bool targetIsDirectory) + { + if (ex == null) + { + throw new ArgumentNullException(nameof(ex)); + } + + uint code = ex.StatusCode.Code; + if (code == StatusCodes.BadNoMatch || + code == StatusCodes.BadNodeIdUnknown || + code == StatusCodes.BadNotFound) + { + return targetIsDirectory + ? new DirectoryNotFoundException( + FormatNotFound(path, "directory"), ex) + : new FileNotFoundException( + FormatNotFound(path, "file"), + path, + ex); + } + + if (code == StatusCodes.BadBrowseNameDuplicated) + { + return new IOException( + FormatPath(path, "already exists"), ex); + } + + if (code == StatusCodes.BadUserAccessDenied || + code == StatusCodes.BadNotWritable || + code == StatusCodes.BadWriteNotSupported || + code == StatusCodes.BadSecurityChecksFailed) + { + return new UnauthorizedAccessException( + FormatPath(path, "access denied"), ex); + } + + if (code == StatusCodes.BadOutOfRange || + code == StatusCodes.BadInvalidState || + code == StatusCodes.BadResourceUnavailable || + code == StatusCodes.BadOutOfMemory || + code == StatusCodes.BadInvalidArgument) + { + return new IOException( + FormatPath(path, ex.Message), ex); + } + + return ex; + } + + /// + /// Wraps the supplied exception as a + /// or depending on the + /// caller's expected target kind. Used by path-resolution code + /// when the server returns no matches at all (no + /// ). + /// + public static Exception NotFound(string? path, bool targetIsDirectory) + { + return targetIsDirectory + ? new DirectoryNotFoundException(FormatNotFound(path, "directory")) + : new FileNotFoundException( + FormatNotFound(path, "file"), + path); + } + + /// + /// Wraps the supplied path/message as an + /// for the ambiguous-resolution case. + /// + public static IOException Ambiguous(string? path, int targetCount) + { + return new IOException( + $"OPC UA path '{path}' resolved to {targetCount} different nodes; ambiguous."); + } + + private static string FormatNotFound(string? path, string kind) + { + return path == null + ? $"OPC UA {kind} not found." + : $"OPC UA {kind} not found: '{path}'."; + } + + private static string FormatPath(string? path, string suffix) + { + return path == null ? suffix : $"'{path}': {suffix}"; + } + } +} diff --git a/Libraries/Opc.Ua.Client/FileSystem/PathCache.cs b/Libraries/Opc.Ua.Client/FileSystem/PathCache.cs new file mode 100644 index 0000000000..24a3559170 --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/PathCache.cs @@ -0,0 +1,240 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; + +namespace Opc.Ua.Client.FileSystem +{ + /// + /// Bounded LRU cache of resolved + /// (parent NodeId, browse name) → child NodeId entries used + /// by the FileSystemClient to avoid repeated + /// TranslateBrowsePathsToNodeIds round-trips for the same + /// path. Not thread-safe — callers must synchronise access. + /// + /// + /// A capacity of zero disables caching. The cache is best-effort: + /// callers must be prepared for a stale entry (the next + /// translate call will fail with BadNodeIdUnknown / + /// BadNoMatch, and the entry must be evicted before + /// retrying). + /// + internal sealed class PathCache + { + public PathCache(int capacity) + { + if (capacity < 0) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + m_capacity = capacity; + m_map = capacity == 0 + ? null + : new Dictionary>(capacity); + m_lru = capacity == 0 ? null : new LinkedList(); + } + + /// + /// Returns the cached child for + /// + , or + /// null when the entry is absent or caching is disabled. + /// + public NodeId? TryGet(NodeId parent, QualifiedName name) + { + if (m_capacity == 0 || + m_map == null || + m_lru == null) + { + return null; + } + var key = new Key(parent, name); + if (!m_map.TryGetValue(key, out LinkedListNode? node)) + { + return null; + } + // Move to front (MRU position). + m_lru.Remove(node); + m_lru.AddFirst(node); + return node.Value.Child; + } + + /// + /// Inserts (or replaces) a cache entry for + /// + → + /// . Evicts the least recently used + /// entry when the capacity is exceeded. + /// + public void Put(NodeId parent, QualifiedName name, NodeId child) + { + if (m_capacity == 0 || + m_map == null || + m_lru == null) + { + return; + } + + var key = new Key(parent, name); + if (m_map.TryGetValue(key, out LinkedListNode? existing)) + { + m_lru.Remove(existing); + existing.Value = new Entry(key, child); + m_lru.AddFirst(existing); + return; + } + + var node = new LinkedListNode(new Entry(key, child)); + m_lru.AddFirst(node); + m_map[key] = node; + + if (m_map.Count > m_capacity) + { + LinkedListNode? lru = m_lru.Last; + if (lru != null) + { + m_lru.RemoveLast(); + m_map.Remove(lru.Value.Key); + } + } + } + + /// + /// Removes the entry for + + /// if present. + /// + public void Invalidate(NodeId parent, QualifiedName name) + { + if (m_capacity == 0 || + m_map == null || + m_lru == null) + { + return; + } + var key = new Key(parent, name); + if (m_map.TryGetValue(key, out LinkedListNode? node)) + { + m_lru.Remove(node); + m_map.Remove(key); + } + } + + /// + /// Removes every entry whose parent equals + /// . Used after a directory mutation to + /// avoid serving stale child NodeIds. + /// + public void InvalidateChildrenOf(NodeId parent) + { + if (m_capacity == 0 || + m_map == null || + m_lru == null) + { + return; + } + // Collect first; mutating during iteration is unsafe. + List? toRemove = null; + foreach (Key key in m_map.Keys) + { + if (key.Parent.Equals(parent)) + { + (toRemove ??= []).Add(key); + } + } + if (toRemove == null) + { + return; + } + foreach (Key key in toRemove) + { + if (m_map.TryGetValue(key, out LinkedListNode? node)) + { + m_lru.Remove(node); + m_map.Remove(key); + } + } + } + + /// + /// Clears every entry. + /// + public void Clear() + { + m_map?.Clear(); + m_lru?.Clear(); + } + + /// + /// Number of entries currently cached. + /// + public int Count => m_map?.Count ?? 0; + + private readonly int m_capacity; + private readonly Dictionary>? m_map; + private readonly LinkedList? m_lru; + + private readonly struct Key : IEquatable + { + public Key(NodeId parent, QualifiedName name) + { + Parent = parent; + Name = name; + } + + public NodeId Parent { get; } + public QualifiedName Name { get; } + + public bool Equals(Key other) + { + return Parent.Equals(other.Parent) && Name.Equals(other.Name); + } + + public override bool Equals(object? obj) + { + return obj is Key k && Equals(k); + } + + public override int GetHashCode() + { + return HashCode.Combine(Parent, Name); + } + } + + private readonly struct Entry + { + public Entry(Key key, NodeId child) + { + Key = key; + Child = child; + } + + public Key Key { get; } + public NodeId Child { get; } + } + } +} diff --git a/Libraries/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.cs b/Libraries/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.cs new file mode 100644 index 0000000000..30ff11cbcb --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/TemporaryFileTransferClient.cs @@ -0,0 +1,184 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Client.FileSystem +{ + /// + /// Client for the OPC UA TemporaryFileTransferType + /// (Part 5 §C.5). Wraps the + /// proxy and exposes + /// the read/commit lifecycles as / + /// values. + /// + /// + /// + /// The temporary-file-transfer pattern is intentionally separate + /// from because its lifecycle does + /// not fit the abstraction: the server + /// allocates a transient file, the client streams data through it, + /// and a final commit (or rollback) signals the server to either + /// publish or discard the result. + /// + /// + /// owns the close lifecycle for + /// the underlying handle: exactly one terminal call is made, + /// either (which + /// invokes CloseAndCommit) or + /// (which falls + /// back to Close). The wrapped + /// itself does NOT close the server handle on disposal. + /// + /// + public sealed class TemporaryFileTransferClient + { + /// + /// Creates a new client rooted at the supplied + /// TemporaryFileTransferType object. + /// + /// The OPC UA session. + /// NodeId of the + /// TemporaryFileTransferType instance. + /// Optional configuration; defaults are + /// applied when null. + public TemporaryFileTransferClient( + ISession session, + NodeId temporaryFileTransferObjectId, + FileSystemClientOptions? options = null) + { + Session = session ?? throw new ArgumentNullException(nameof(session)); + if (temporaryFileTransferObjectId.IsNull) + { + throw new ArgumentNullException(nameof(temporaryFileTransferObjectId)); + } + ObjectId = temporaryFileTransferObjectId; + Options = (options ?? new FileSystemClientOptions()).Clone(); + Options.Validate(); + Proxy = new TemporaryFileTransferTypeClient( + session, + temporaryFileTransferObjectId, + session.MessageContext.Telemetry); + } + + /// The session. + public ISession Session { get; } + + /// The NodeId of the underlying + /// TemporaryFileTransferType object. + public NodeId ObjectId { get; } + + /// The (cloned, immutable) configuration. + public FileSystemClientOptions Options { get; } + + /// The underlying generated proxy. + public TemporaryFileTransferTypeClient Proxy { get; } + + /// + /// Asks the server to generate a temporary file for reading. + /// Returns a wrapping the server + /// handle; disposing the stream issues Close per the + /// regular FileType lifecycle. + /// + /// Server-defined generation + /// options; pass default for "no options". + /// Cancellation token. + public async ValueTask GenerateFileForReadAsync( + Variant generateOptions = default, + CancellationToken ct = default) + { + (NodeId fileNodeId, uint handle, _) = await Proxy + .GenerateFileForReadAsync(generateOptions, ct) + .ConfigureAwait(false); + + var fileProxy = new FileTypeClient( + Session, + fileNodeId, + Session.MessageContext.Telemetry); + + try + { + return new UaFileStream( + fileProxy, + handle, + UaFileMode.Read, + initialLength: 0, + initialPosition: 0, + chunkSize: Options.ChunkSize); + } + catch + { + try + { + await fileProxy.CloseAsync(handle, CancellationToken.None) + .ConfigureAwait(false); + } + catch + { + // Best-effort cleanup. + } + throw; + } + } + + /// + /// Asks the server to allocate a temporary file for writing. + /// The returned owns the + /// close lifecycle: call + /// to publish + /// the file or rely on + /// to roll back. + /// + /// Server-defined generation + /// options. + /// Cancellation token. + public async ValueTask GenerateFileForWriteAsync( + Variant generateOptions = default, + CancellationToken ct = default) + { + (NodeId fileNodeId, uint handle) = await Proxy + .GenerateFileForWriteAsync(generateOptions, ct) + .ConfigureAwait(false); + + var fileProxy = new FileTypeClient( + Session, + fileNodeId, + Session.MessageContext.Telemetry); + + return new UaTemporaryWriteFile( + Proxy, + fileProxy, + fileNodeId, + handle, + Options.ChunkSize); + } + } +} diff --git a/Libraries/Opc.Ua.Client/FileSystem/UaDirectoryInfo.cs b/Libraries/Opc.Ua.Client/FileSystem/UaDirectoryInfo.cs new file mode 100644 index 0000000000..7485038248 --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/UaDirectoryInfo.cs @@ -0,0 +1,166 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Client.FileSystem +{ + /// + /// Strongly-typed handle to a single OPC UA FileDirectoryType + /// instance. Mirrors . + /// + public sealed class UaDirectoryInfo : UaFileSystemInfo + { + internal UaDirectoryInfo( + FileSystemClient owner, + UaDirectoryInfo? parent, + NodeId nodeId, + QualifiedName browseName, + IReadOnlyList segments) + : base(owner, parent, nodeId, browseName, segments) + { + Proxy = new FileDirectoryTypeClient( + owner.Session, + nodeId, + owner.Session.MessageContext.Telemetry); + } + + /// + public override bool IsDirectory => true; + + /// + /// The underlying proxy. + /// + public FileDirectoryTypeClient Proxy { get; } + + /// + public override ValueTask RefreshAsync(CancellationToken ct = default) + { + // FileDirectoryType has no scalar properties to refresh; the + // child-set is inherently lazy via EnumerateAsync. + return default; + } + + /// + /// Enumerates the immediate children of this directory. + /// Returns both files () and + /// sub-directories () in browse + /// order. + /// + public IAsyncEnumerable EnumerateAsync( + CancellationToken ct = default) + { + return Owner.EnumerateChildrenAsync(this, includeFiles: true, includeDirectories: true, ct); + } + + /// + /// Enumerates only the file children of this directory. + /// + public async IAsyncEnumerable EnumerateFilesAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] + CancellationToken ct = default) + { + await foreach (UaFileSystemInfo child in Owner + .EnumerateChildrenAsync(this, includeFiles: true, includeDirectories: false, ct) + .ConfigureAwait(false)) + { + if (child is UaFileInfo file) + { + yield return file; + } + } + } + + /// + /// Enumerates only the directory children of this directory. + /// + public async IAsyncEnumerable EnumerateDirectoriesAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] + CancellationToken ct = default) + { + await foreach (UaFileSystemInfo child in Owner + .EnumerateChildrenAsync(this, includeFiles: false, includeDirectories: true, ct) + .ConfigureAwait(false)) + { + if (child is UaDirectoryInfo dir) + { + yield return dir; + } + } + } + + /// + /// Creates a new sub-directory in this directory. + /// + /// The new directory's leaf name. Must NOT + /// include a namespace prefix or a path separator. + /// Cancellation token. + public ValueTask CreateSubdirectoryAsync( + string name, + CancellationToken ct = default) + { + return Owner.CreateDirectoryInAsync(this, name, ct); + } + + /// + /// Creates a new empty file in this directory. The server is + /// asked NOT to immediately open the file + /// (requestFileOpen: false); call + /// + /// when you are ready to write. + /// + /// The new file's leaf name. Must NOT + /// include a namespace prefix or a path separator. + /// Cancellation token. + public ValueTask CreateFileAsync( + string name, + CancellationToken ct = default) + { + return Owner.CreateFileInAsync(this, name, ct); + } + + /// + /// Deletes this directory. When + /// is false the directory + /// must be empty (an is + /// thrown otherwise). When true the server's recursive + /// Delete primitive is invoked exactly once (no + /// client-side traversal). + /// + public ValueTask DeleteAsync( + bool recursive, + CancellationToken ct = default) + { + return Owner.DeleteCoreAsync(this, recursive, ct); + } + } +} diff --git a/Libraries/Opc.Ua.Client/FileSystem/UaFileInfo.cs b/Libraries/Opc.Ua.Client/FileSystem/UaFileInfo.cs new file mode 100644 index 0000000000..0cb836513d --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/UaFileInfo.cs @@ -0,0 +1,345 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Client.FileSystem +{ + /// + /// Strongly-typed handle to a single OPC UA FileType + /// instance. Mirrors . + /// + /// + /// File metadata properties (, + /// , , …) are populated + /// lazily by ; before the first call they + /// return their default values. The property + /// / are advisory + /// only (TOCTOU-prone): rely on the server's + /// response + /// to determine whether write access is actually granted. + /// + public sealed class UaFileInfo : UaFileSystemInfo + { + internal UaFileInfo( + FileSystemClient owner, + UaDirectoryInfo? parent, + NodeId nodeId, + QualifiedName browseName, + IReadOnlyList segments) + : base(owner, parent, nodeId, browseName, segments) + { + Proxy = new FileTypeClient( + owner.Session, + nodeId, + owner.Session.MessageContext.Telemetry); + } + + /// + public override bool IsDirectory => false; + + /// The cached Size property in bytes. + public ulong Size { get; private set; } + + /// The cached Writable property (advisory). + public bool Writable { get; private set; } + + /// The cached UserWritable property (advisory). + public bool UserWritable { get; private set; } + + /// The cached OpenCount property. + public ushort OpenCount { get; private set; } + + /// The cached MimeType property; null + /// when the server does not expose it. + public string? MimeType { get; private set; } + + /// The cached MaxByteStringLength property; + /// null when the server does not expose it. + public uint? MaxByteStringLength { get; private set; } + + /// The cached LastModifiedTime property; + /// null when the server does not expose it. + public DateTime? LastModifiedTime { get; private set; } + + /// + /// The underlying proxy. + /// Exposed so advanced callers can reach the raw method + /// wrappers when the high-level API does not suffice. + /// + public FileTypeClient Proxy { get; } + + /// + public override async ValueTask RefreshAsync(CancellationToken ct = default) + { + FileMetadata metadata = await Owner + .ReadFileMetadataAsync(NodeId, FullPath, ct) + .ConfigureAwait(false); + + Size = metadata.Size ?? 0UL; + Writable = metadata.Writable ?? false; + UserWritable = metadata.UserWritable ?? false; + OpenCount = metadata.OpenCount ?? (ushort)0; + MimeType = metadata.MimeType; + MaxByteStringLength = metadata.MaxByteStringLength; + LastModifiedTime = metadata.LastModifiedTime; + } + + /// + /// Opens this file with the supplied . + /// + /// The combined open-mode flags. + /// Cancellation token. + /// A wrapping the server + /// handle. + /// + public async ValueTask OpenAsync( + UaFileMode mode, + CancellationToken ct = default) + { + if (mode == 0) + { + throw new ArgumentException( + "UaFileMode must include at least one of Read or Write.", + nameof(mode)); + } + + uint handle; + try + { + handle = await Proxy.OpenAsync((byte)mode, ct).ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + throw FileSystemErrors.Translate(ex, FullPath, targetIsDirectory: false); + } + + long initialPosition = 0; + long initialLength = (long)Size; + + try + { + if ((mode & UaFileMode.Append) != 0) + { + // Spec leaves this server-defined; query for accuracy. + ulong serverPos = await Proxy.GetPositionAsync(handle, ct) + .ConfigureAwait(false); + initialPosition = checked((long)serverPos); + if (initialPosition > initialLength) + { + initialLength = initialPosition; + } + } + else if ((mode & UaFileMode.EraseExisting) != 0) + { + initialLength = 0; + } + } + catch + { + // If GetPosition fails after a successful Open we still + // own the handle and must release it. + try + { + await Proxy.CloseAsync(handle, CancellationToken.None) + .ConfigureAwait(false); + } + catch + { + // Best-effort cleanup. + } + throw; + } + + int chunk = Owner.Options.ChunkSize; + if (MaxByteStringLength is uint cap && cap > 0 && cap < int.MaxValue) + { + chunk = Math.Min(chunk, (int)cap); + } + + return new UaFileStream( + Proxy, + handle, + mode, + initialLength, + initialPosition, + chunk); + } + + /// + /// Equivalent to with + /// . + /// + public ValueTask OpenReadAsync(CancellationToken ct = default) + { + return OpenAsync(UaFileMode.Read, ct); + } + + /// + /// Equivalent to with + /// | + /// (truncates). + /// + public ValueTask OpenWriteAsync(CancellationToken ct = default) + { + return OpenAsync(UaFileMode.Write | UaFileMode.EraseExisting, ct); + } + + /// + /// Equivalent to with + /// | . + /// + public ValueTask OpenAppendAsync(CancellationToken ct = default) + { + return OpenAsync(UaFileMode.Write | UaFileMode.Append, ct); + } + + /// + /// Reads the entire file contents into memory. Throws when the + /// file size exceeds + /// . + /// + /// + public async ValueTask ReadAllBytesAsync(CancellationToken ct = default) + { + UaFileStream stream = await OpenReadAsync(ct).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + long length = stream.Length; + if (length > Owner.Options.MaxBufferedReadSize) + { + throw ServiceResultException.Create( + StatusCodes.BadEncodingLimitsExceeded, + "File '{0}' size ({1}) exceeds MaxBufferedReadSize ({2}).", + FullPath, + length, + Owner.Options.MaxBufferedReadSize); + } + + using var ms = new MemoryStream(length > 0 ? (int)length : 0); + int rentSize = length > 0 + ? (int)Math.Min(length, Owner.Options.ChunkSize) + : Owner.Options.ChunkSize; + byte[] rented = ArrayPool.Shared.Rent(rentSize); + try + { + while (true) + { +#if NETSTANDARD2_1_OR_GREATER || NET + int read = await stream.ReadAsync(rented.AsMemory(), ct) + .ConfigureAwait(false); +#else + int read = await stream.ReadAsync(rented, 0, rented.Length, ct) + .ConfigureAwait(false); +#endif + if (read == 0) + { + break; + } + ms.Write(rented, 0, read); + if (ms.Length > Owner.Options.MaxBufferedReadSize) + { + throw ServiceResultException.Create( + StatusCodes.BadEncodingLimitsExceeded, + "File '{0}' size exceeds MaxBufferedReadSize ({1}).", + FullPath, + Owner.Options.MaxBufferedReadSize); + } + } + } + finally + { + ArrayPool.Shared.Return(rented); + } + return ms.ToArray(); + } + } + + /// + /// Reads the entire file contents and decodes it as text using + /// the supplied (UTF-8 by default). + /// + public async ValueTask ReadAllTextAsync( + Encoding? encoding = null, + CancellationToken ct = default) + { + byte[] bytes = await ReadAllBytesAsync(ct).ConfigureAwait(false); + return (encoding ?? Encoding.UTF8).GetString(bytes); + } + + /// + /// Truncates the file and writes as + /// the new contents. + /// + public async ValueTask WriteAllBytesAsync( + ReadOnlyMemory bytes, + CancellationToken ct = default) + { + UaFileStream stream = await OpenWriteAsync(ct).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + if (!bytes.IsEmpty) + { +#if NETSTANDARD2_1_OR_GREATER || NET + await stream.WriteAsync(bytes, ct).ConfigureAwait(false); +#else + byte[] buffer = bytes.ToArray(); + await stream.WriteAsync(buffer, 0, buffer.Length, ct) + .ConfigureAwait(false); +#endif + } + } + } + + /// + /// Truncates the file and writes + /// using the supplied (UTF-8 by + /// default). + /// + /// is null. + public ValueTask WriteAllTextAsync( + string contents, + Encoding? encoding = null, + CancellationToken ct = default) + { + if (contents == null) + { + throw new ArgumentNullException(nameof(contents)); + } + byte[] bytes = (encoding ?? Encoding.UTF8).GetBytes(contents); + return WriteAllBytesAsync(bytes, ct); + } + } +} diff --git a/Libraries/Opc.Ua.Client/FileSystem/UaFileMode.cs b/Libraries/Opc.Ua.Client/FileSystem/UaFileMode.cs new file mode 100644 index 0000000000..3b327f8955 --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/UaFileMode.cs @@ -0,0 +1,84 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; + +namespace Opc.Ua.Client.FileSystem +{ + /// + /// File access modes for the OPC UA FileType.Open method, + /// mirrored as flags so callers don't have to remember the raw + /// Part 5 bit values. + /// + /// + /// The numeric values match the OPC UA OpenFileMode + /// enumeration defined in Part 5 §C.4.4. + /// + [Flags] + public enum UaFileMode : byte + { + /// + /// Sentinel value indicating that no mode has been specified. + /// Passing this to + /// + /// is rejected with at + /// runtime; the value exists so callers can use it as a default + /// and check explicitly. + /// + None = 0, + + /// + /// Open for reading. + /// + Read = 1, + + /// + /// Open for writing. + /// + Write = 2, + + /// + /// Combine with to truncate the file on + /// open. + /// + EraseExisting = 4, + + /// + /// Combine with to position the cursor at + /// the end of the file on open. + /// + Append = 8, + + /// + /// Convenience combination of and + /// . + /// + ReadWrite = Read | Write + } +} diff --git a/Libraries/Opc.Ua.Client/FileSystem/UaFileStream.cs b/Libraries/Opc.Ua.Client/FileSystem/UaFileStream.cs new file mode 100644 index 0000000000..fc55461559 --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/UaFileStream.cs @@ -0,0 +1,572 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Buffers; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Client.FileSystem +{ + /// + /// A -derived wrapper around an OPC UA + /// FileType handle. Async members hit the wire via the + /// supplied proxy; sync members + /// forward to their async counterparts via + /// GetAwaiter().GetResult(). + /// + /// + /// + /// serialises concurrent calls from + /// multiple threads on the same instance via an internal + /// ; the resulting behaviour matches + /// 's "not thread-safe but doesn't corrupt + /// state" contract. + /// + /// + /// is tracked locally because OPC UA does not + /// expose a "current length" accessor on the file handle. It is + /// initialised at construction (typically from the file's + /// Size property) and bumped whenever a write extends past + /// the current length. Callers that mutate the underlying file + /// through other handles should call + /// on the owning info object + /// before relying on . + /// + /// + /// is tracked locally and pushed to the + /// server lazily — only when the requested cursor diverges from + /// the last successfully transmitted position. + /// + /// + /// Sync members (Read, Write, Seek, + /// Flush, Dispose) forward to the async overrides + /// via GetAwaiter().GetResult(). Calling them on a + /// single-threaded synchronization context (e.g. WPF UI thread) + /// can deadlock — prefer the async overrides. + /// + /// + public sealed class UaFileStream : Stream +#if !(NETSTANDARD2_1_OR_GREATER || NET) + , System.IAsyncDisposable +#endif + { + internal UaFileStream( + FileTypeClient proxy, + uint handle, + UaFileMode mode, + long initialLength, + long initialPosition, + int chunkSize) + { + m_proxy = proxy ?? throw new ArgumentNullException(nameof(proxy)); + Handle = handle; + Mode = mode; + m_length = initialLength < 0 ? 0 : initialLength; + m_position = initialPosition < 0 ? 0 : initialPosition; + m_serverPosition = m_position; + m_chunkSize = chunkSize <= 0 ? DefaultChunkSize : chunkSize; + } + + /// + public override bool CanRead => + !m_disposed && (Mode & UaFileMode.Read) != 0; + + /// + public override bool CanWrite => + !m_disposed && (Mode & UaFileMode.Write) != 0; + + /// + public override bool CanSeek => !m_disposed; + + /// + public override bool CanTimeout => false; + + /// + public override long Length + { + get + { + ThrowIfDisposed(); + return Volatile.Read(ref m_length); + } + } + + /// + public override long Position + { + get + { + ThrowIfDisposed(); + return Volatile.Read(ref m_position); + } + set => Seek(value, SeekOrigin.Begin); + } + + /// + /// The OPC UA FileType handle wrapped by this stream. + /// + public uint Handle { get; } + + /// + /// The mode this stream was opened with. + /// + public UaFileMode Mode { get; } + + /// + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + ValidateBuffer(buffer, offset, count); + return ReadCoreAsync(buffer, offset, count, cancellationToken); + } + + /// + public override Task WriteAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + ValidateBuffer(buffer, offset, count); + return WriteCoreAsync(buffer, offset, count, cancellationToken); + } + +#if NETSTANDARD2_1_OR_GREATER || NET + /// + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + if (buffer.IsEmpty) + { + ThrowIfDisposed(); + return 0; + } + return await ReadIntoSpanAsync(buffer, cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + { + if (buffer.IsEmpty) + { + ThrowIfDisposed(); + return; + } + await WriteFromSpanAsync(buffer, cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask DisposeAsync() + { + await DisposeAsyncCore().ConfigureAwait(false); + // base.DisposeAsync() calls Dispose(false) internally on the + // standard Stream implementation; calling it keeps CA2215 + // happy while keeping our (already idempotent) Dispose(bool) + // override in the chain. + await base.DisposeAsync().ConfigureAwait(false); + GC.SuppressFinalize(this); + } +#else + /// + /// Asynchronously closes the underlying server file handle. + /// Implements the recommended DisposeAsync pattern from + /// the .NET docs. + /// + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore().ConfigureAwait(false); + Dispose(disposing: false); + GC.SuppressFinalize(this); + } +#endif + + /// + public override Task FlushAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + // OPC UA file handles do not expose an explicit flush; writes + // are applied immediately on the server. + return Task.CompletedTask; + } + + /// + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBuffer(buffer, offset, count); + return ReadCoreAsync(buffer, offset, count, CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + + /// + public override void Write(byte[] buffer, int offset, int count) + { + ValidateBuffer(buffer, offset, count); + WriteCoreAsync(buffer, offset, count, CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + + /// + public override long Seek(long offset, SeekOrigin origin) + { + ThrowIfDisposed(); + long target = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => Volatile.Read(ref m_position) + offset, + SeekOrigin.End => Volatile.Read(ref m_length) + offset, + _ => throw new ArgumentOutOfRangeException(nameof(origin)) + }; + if (target < 0) + { + throw new IOException("Cannot seek before the start of the stream."); + } + Volatile.Write(ref m_position, target); + return target; + } + + /// + public override void SetLength(long value) + { + // OPC UA's FileType offers no truncation primitive; callers + // that need truncation must reopen the file with + // EraseExisting. + throw new NotSupportedException( + "OPC UA FileType does not support truncation; reopen the file with UaFileMode.EraseExisting."); + } + + /// + public override void Flush() + { + // No-op (see FlushAsync). + } + + /// + protected override void Dispose(bool disposing) + { + if (m_disposed) + { + base.Dispose(disposing); + return; + } + if (disposing) + { + // Synchronous fallback for callers that use 'using' + // instead of 'await using'. Mirrors the recommended + // dispose pattern; the async path (DisposeAsync) is + // preferred to avoid the GetAwaiter().GetResult() + // dance. + try + { + DisposeAsyncCore().AsTask().GetAwaiter().GetResult(); + } + catch + { + // Best-effort: async path surfaces exceptions. + } + } + base.Dispose(disposing); + } + + /// + /// Performs the asynchronous cleanup of the server-side file + /// handle. Idempotent. + /// + /// + /// Implements the recommended async dispose core pattern from + /// . + /// The class is sealed, so this helper is private + /// rather than the protected virtual shape recommended + /// for unsealed types. + /// + private async ValueTask DisposeAsyncCore() + { + if (m_disposed) + { + return; + } + await m_lock.WaitAsync().ConfigureAwait(false); + try + { + if (m_disposed) + { + return; + } + m_disposed = true; + try + { + await m_proxy.CloseAsync(Handle, CancellationToken.None) + .ConfigureAwait(false); + } + catch (ServiceResultException) + { + // Server may have already torn down the handle on + // session close — best-effort close. + } + } + finally + { + m_lock.Release(); + m_lock.Dispose(); + } + } + + /// + /// Marks this stream disposed without sending a Close to the + /// server. Used by when the + /// owner has already closed (or committed) the handle through a + /// different path and wants to prevent further writes through + /// this stream wrapper. + /// + internal void MarkDisposedWithoutClosing() + { + if (m_disposed) + { + return; + } + m_lock.Wait(); + try + { + m_disposed = true; + } + finally + { + m_lock.Release(); + } + } + + private async Task ReadCoreAsync( + byte[] buffer, + int offset, + int count, + CancellationToken ct) + { + ThrowIfDisposed(); + if (!CanRead) + { + throw new NotSupportedException("Stream not opened for reading."); + } + if (count == 0) + { + return 0; + } + + await m_lock.WaitAsync(ct).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + await SyncServerPositionAsync(ct).ConfigureAwait(false); + + int total = 0; + while (total < count) + { + int chunkLen = Math.Min(m_chunkSize, count - total); + ByteString data = await m_proxy.ReadAsync( + Handle, + chunkLen, + ct).ConfigureAwait(false); + + byte[] payload = data.ToArray() ?? []; + if (payload.Length == 0) + { + break; + } + + Buffer.BlockCopy(payload, 0, buffer, offset + total, payload.Length); + total += payload.Length; + m_position += payload.Length; + m_serverPosition = m_position; + if (m_position > m_length) + { + m_length = m_position; + } + if (payload.Length < chunkLen) + { + break; + } + } + return total; + } + finally + { + m_lock.Release(); + } + } + + private async Task WriteCoreAsync( + byte[] buffer, + int offset, + int count, + CancellationToken ct) + { + ThrowIfDisposed(); + if (!CanWrite) + { + throw new NotSupportedException("Stream not opened for writing."); + } + if (count == 0) + { + return; + } + + await m_lock.WaitAsync(ct).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + await SyncServerPositionAsync(ct).ConfigureAwait(false); + + int written = 0; + while (written < count) + { + int chunkLen = Math.Min(m_chunkSize, count - written); + var slice = new byte[chunkLen]; + Buffer.BlockCopy(buffer, offset + written, slice, 0, chunkLen); + await m_proxy.WriteAsync( + Handle, + slice.ToByteString(), + ct).ConfigureAwait(false); + written += chunkLen; + m_position += chunkLen; + m_serverPosition = m_position; + if (m_position > m_length) + { + m_length = m_position; + } + } + } + finally + { + m_lock.Release(); + } + } + +#if NETSTANDARD2_1_OR_GREATER || NET + private async ValueTask ReadIntoSpanAsync( + Memory buffer, + CancellationToken ct) + { + byte[] rented = ArrayPool.Shared.Rent(buffer.Length); + try + { + int read = await ReadCoreAsync(rented, 0, buffer.Length, ct) + .ConfigureAwait(false); + if (read > 0) + { + rented.AsSpan(0, read).CopyTo(buffer.Span); + } + return read; + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + private async ValueTask WriteFromSpanAsync( + ReadOnlyMemory buffer, + CancellationToken ct) + { + byte[] rented = ArrayPool.Shared.Rent(buffer.Length); + try + { + buffer.Span.CopyTo(rented); + await WriteCoreAsync(rented, 0, buffer.Length, ct) + .ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } +#endif + + private async Task SyncServerPositionAsync(CancellationToken ct) + { + if (m_position == m_serverPosition) + { + return; + } + await m_proxy.SetPositionAsync( + Handle, + (ulong)m_position, + ct).ConfigureAwait(false); + m_serverPosition = m_position; + } + + private void ThrowIfDisposed() + { + if (m_disposed) + { + throw new ObjectDisposedException(nameof(UaFileStream)); + } + } + + private static void ValidateBuffer(byte[] buffer, int offset, int count) + { + if (buffer == null) + { + throw new ArgumentNullException(nameof(buffer)); + } + if (offset < 0) + { + throw new ArgumentOutOfRangeException(nameof(offset)); + } + if (count < 0) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + if (buffer.Length - offset < count) + { + throw new ArgumentException( + "Offset + count exceeds buffer length.", + nameof(count)); + } + } + + private const int DefaultChunkSize = 64 * 1024; + + private readonly FileTypeClient m_proxy; + private readonly int m_chunkSize; + private readonly SemaphoreSlim m_lock = new(1, 1); + + private long m_length; + private long m_position; + private long m_serverPosition; + private bool m_disposed; + } +} diff --git a/Libraries/Opc.Ua.Client/FileSystem/UaFileSystemInfo.cs b/Libraries/Opc.Ua.Client/FileSystem/UaFileSystemInfo.cs new file mode 100644 index 0000000000..3625b65bc4 --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/UaFileSystemInfo.cs @@ -0,0 +1,193 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Client.FileSystem +{ + /// + /// Common base for and + /// ; mirrors + /// . + /// + public abstract class UaFileSystemInfo + { + internal UaFileSystemInfo( + FileSystemClient owner, + UaDirectoryInfo? parent, + NodeId nodeId, + QualifiedName browseName, + IReadOnlyList segments) + { + Owner = owner ?? throw new ArgumentNullException(nameof(owner)); + Parent = parent; + if (nodeId.IsNull) + { + throw new ArgumentNullException(nameof(nodeId)); + } + NodeId = nodeId; + BrowseName = browseName; + Segments = segments; + } + + /// + /// The that produced this info + /// object. All operations that need to call back to the server + /// route through this owner. + /// + public FileSystemClient Owner { get; } + + /// + /// The parent directory; null only for the root. + /// + public UaDirectoryInfo? Parent { get; } + + /// + /// The OPC UA of the underlying object. + /// + public NodeId NodeId { get; } + + /// + /// The OPC UA of the underlying + /// object. + /// + public QualifiedName BrowseName { get; } + + /// + /// The unqualified leaf name of this file or directory. + /// + /// + /// This property mirrors + /// : it discards the + /// namespace prefix. Use when round-trip + /// fidelity matters. + /// + public string Name => BrowseName.Name ?? string.Empty; + + /// + /// The canonical, namespace-aware path of this object relative + /// to the root, in + /// form (always begins with '/'). + /// + public string FullPath => UaPath.Format(Segments); + + /// + /// The path segments leading to this object. + /// + public IReadOnlyList Segments { get; } + + /// + /// true for ; false + /// for . + /// + public abstract bool IsDirectory { get; } + + /// + /// Re-reads metadata from the server. Subclasses populate type- + /// specific properties. + /// + public abstract ValueTask RefreshAsync(CancellationToken ct = default); + + /// + /// Deletes this file or empty directory. For non-empty + /// directories, use + /// with recursive: true. + /// + public virtual ValueTask DeleteAsync(CancellationToken ct = default) + { + return Owner.DeleteCoreAsync(this, recursive: false, ct); + } + + /// + /// Moves this object to . The + /// destination's parent directory must exist. + /// + public ValueTask MoveToAsync( + string destPath, + CancellationToken ct = default) + { + return Owner.MoveOrCopyAsync(this, destPath, copy: false, ct); + } + + /// + /// Moves this object into , + /// optionally renaming it via . + /// + public ValueTask MoveToAsync( + UaDirectoryInfo destinationDirectory, + string? newName = null, + CancellationToken ct = default) + { + return Owner.MoveOrCopyAsync( + this, + destinationDirectory, + newName ?? Name, + copy: false, + ct); + } + + /// + /// Copies this object to . + /// + public ValueTask CopyToAsync( + string destPath, + CancellationToken ct = default) + { + return Owner.MoveOrCopyAsync(this, destPath, copy: true, ct); + } + + /// + /// Copies this object into + /// , optionally renaming + /// it via . + /// + public ValueTask CopyToAsync( + UaDirectoryInfo destinationDirectory, + string? newName = null, + CancellationToken ct = default) + { + return Owner.MoveOrCopyAsync( + this, + destinationDirectory, + newName ?? Name, + copy: true, + ct); + } + + /// + public override string ToString() + { + return FullPath; + } + } +} diff --git a/Libraries/Opc.Ua.Client/FileSystem/UaPath.cs b/Libraries/Opc.Ua.Client/FileSystem/UaPath.cs new file mode 100644 index 0000000000..229669b18c --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/UaPath.cs @@ -0,0 +1,293 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Text; + +namespace Opc.Ua.Client.FileSystem +{ + /// + /// -equivalent helpers for the OPC UA + /// FileSystem client. Paths use the forward slash '/' as + /// separator and each segment is parsed via + /// , so the + /// "<ns>:<name>" form is supported (with the + /// <ns>: prefix omitted when the namespace index is + /// zero). + /// + /// + /// + /// The empty string and "/" both denote the root directory. + /// Leading and trailing separators are tolerated and trimmed during + /// parsing. Internal duplicate separators ("a//b") collapse + /// into a single one and an empty middle segment is rejected. + /// + /// + /// Path comparisons are namespace aware: siblings + /// "1:foo" and "2:foo" are different paths and produce + /// different cache keys, even though their + /// is identical. + /// + /// + public static class UaPath + { + /// + /// The path separator character used by . + /// + public const char Separator = '/'; + + /// + /// Returns the canonical string form of the root path + /// (a single '/'). + /// + public const string Root = "/"; + + /// + /// Splits into its + /// segments. The empty string and "/" both yield an empty + /// array. Leading and trailing slashes are tolerated; embedded + /// empty segments throw . + /// + /// A forward-slash separated path. + /// The parsed path segments. + /// is null. + /// + /// contains an empty middle segment + /// (e.g. "a//b") or a segment whose namespace prefix is + /// not a valid . + /// + public static QualifiedName[] Parse(string path) + { + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + + ReadOnlySpan remaining = path.AsSpan(); + + // Trim a single leading / trailing slash; multiple are illegal. + if (remaining.Length > 0 && remaining[0] == Separator) + { + remaining = remaining[1..]; + } + if (remaining.Length > 0 && remaining[^1] == Separator) + { + remaining = remaining[..^1]; + } + + if (remaining.Length == 0) + { + return []; + } + + var segments = new List(); + int start = 0; + for (int i = 0; i <= remaining.Length; i++) + { + if (i == remaining.Length || remaining[i] == Separator) + { + if (i == start) + { + throw new ArgumentException( + $"Invalid path '{path}': empty segment.", + nameof(path)); + } + string segment = remaining[start..i].ToString(); + segments.Add(ParseSegment(segment, path)); + start = i + 1; + } + } + + return [.. segments]; + } + + /// + /// Returns the canonical string form of . + /// Always begins with '/'. The root produces + /// "/". Each segment is rendered via + /// so the namespace + /// index is always preserved. + /// + /// The path segments. + /// A canonical, namespace-aware string form. + public static string Format(IReadOnlyList segments) + { + if (segments == null || segments.Count == 0) + { + return Root; + } + var sb = new StringBuilder(); + for (int i = 0; i < segments.Count; i++) + { + sb.Append(Separator).Append(FormatSegment(segments[i])); + } + return sb.ToString(); + } + + /// + /// Returns the canonical string form of a single segment as it + /// appears in a path (e.g. "1:foo" for a non-zero + /// namespace index, "foo" for namespace zero). + /// + /// The segment. + /// The formatted segment. + /// + /// has a null or empty + /// . + /// + public static string FormatSegment(QualifiedName segment) + { + if (string.IsNullOrEmpty(segment.Name)) + { + throw new ArgumentException( + "Path segment must have a non-empty Name.", + nameof(segment)); + } + if (segment.NamespaceIndex == 0) + { + return segment.Name; + } + return $"{segment.NamespaceIndex}:{segment.Name}"; + } + + /// + /// Combines two paths in the manner of + /// : if + /// begins with '/' it is + /// returned unchanged; otherwise the two are joined by exactly + /// one separator. + /// + /// The base path; may be null or empty + /// to mean the root. + /// The relative or absolute path. + /// The combined path. + /// + /// is null. + /// + public static string Combine(string left, string right) + { + if (right == null) + { + throw new ArgumentNullException(nameof(right)); + } + + if (right.Length > 0 && right[0] == Separator) + { + return Normalize(right); + } + + QualifiedName[] leftSegments = Parse(left ?? string.Empty); + QualifiedName[] rightSegments = Parse(right); + + if (rightSegments.Length == 0) + { + return Format(leftSegments); + } + + var combined = new List(leftSegments.Length + rightSegments.Length); + combined.AddRange(leftSegments); + combined.AddRange(rightSegments); + return Format(combined); + } + + /// + /// Returns the parent directory of , or + /// null if is the root or empty. + /// + /// The path. + /// The directory portion in canonical form, or + /// null. + public static string? GetDirectoryName(string path) + { + QualifiedName[] segments = Parse(path); + if (segments.Length == 0) + { + return null; + } + if (segments.Length == 1) + { + return Root; + } + var parent = new QualifiedName[segments.Length - 1]; + Array.Copy(segments, parent, parent.Length); + return Format(parent); + } + + /// + /// Returns the leaf segment of as a + /// , or + /// when the path is the root. + /// + /// The path. + /// The leaf segment. + public static QualifiedName GetFileName(string path) + { + QualifiedName[] segments = Parse(path); + if (segments.Length == 0) + { + return QualifiedName.Null; + } + return segments[^1]; + } + + /// + /// Returns the canonical string form of . + /// + /// The path. + /// The canonicalised path string. + public static string Normalize(string path) + { + return Format(Parse(path)); + } + + private static QualifiedName ParseSegment(string segment, string fullPath) + { + int colon = segment.IndexOf(':', StringComparison.Ordinal); + if (colon < 0) + { + return new QualifiedName(segment); + } + if (colon == 0 || colon == segment.Length - 1) + { + throw new ArgumentException( + $"Invalid path '{fullPath}': segment '{segment}' has an empty namespace prefix or empty name.", + nameof(fullPath)); + } + string nsToken = segment[..colon]; + if (!ushort.TryParse(nsToken, out ushort ns)) + { + throw new ArgumentException( + $"Invalid path '{fullPath}': segment '{segment}' has a non-numeric namespace prefix.", + nameof(fullPath)); + } + return new QualifiedName(segment[(colon + 1)..], ns); + } + } +} diff --git a/Libraries/Opc.Ua.Client/FileSystem/UaTemporaryWriteFile.cs b/Libraries/Opc.Ua.Client/FileSystem/UaTemporaryWriteFile.cs new file mode 100644 index 0000000000..876d98de58 --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/UaTemporaryWriteFile.cs @@ -0,0 +1,307 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Client.FileSystem +{ + /// + /// Wraps a server-allocated temporary write file (returned by + /// ). + /// Owns the close lifecycle: exactly one terminal call — + /// (CloseAndCommit) or + /// (Close, server rollback) — is sent + /// to the server. + /// + public sealed class UaTemporaryWriteFile : IAsyncDisposable, IDisposable + { + internal UaTemporaryWriteFile( + TemporaryFileTransferTypeClient transferProxy, + FileTypeClient fileProxy, + NodeId fileNodeId, + uint handle, + int chunkSize) + { + m_transferProxy = transferProxy ?? throw new ArgumentNullException(nameof(transferProxy)); + m_fileProxy = fileProxy ?? throw new ArgumentNullException(nameof(fileProxy)); + FileNodeId = fileNodeId; + m_handle = handle; + m_inner = new UaFileStream( + fileProxy, + handle, + UaFileMode.Write, + initialLength: 0, + initialPosition: 0, + chunkSize); + Stream = new NonClosingStreamWrapper(m_inner); + } + + /// + /// The NodeId of the temporary file allocated by the server. + /// + public NodeId FileNodeId { get; } + + /// + /// A write-only wrapper around + /// the underlying server handle. The wrapper's + /// is suppressed — + /// disposal is owned by and + /// must go through or + /// . + /// + public Stream Stream { get; } + + /// + /// Closes the temporary file and commits it on the server via + /// the parent TemporaryFileTransferType.CloseAndCommit + /// method. Returns the NodeId of the completion state machine + /// the server uses to report progress (may be + /// when the server elects not to + /// expose one). + /// + /// Cancellation token. + public async ValueTask CommitAsync(CancellationToken ct = default) + { + if (m_terminated) + { + return m_completionStateMachine; + } + await m_lock.WaitAsync(ct).ConfigureAwait(false); + try + { + if (m_terminated) + { + return m_completionStateMachine; + } + m_terminated = true; + m_completionStateMachine = await m_transferProxy + .CloseAndCommitAsync(m_handle, ct) + .ConfigureAwait(false); + m_inner.MarkDisposedWithoutClosing(); + return m_completionStateMachine; + } + finally + { + m_lock.Release(); + m_lock.Dispose(); + } + } + + /// + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore().ConfigureAwait(false); + Dispose(disposing: false); + GC.SuppressFinalize(this); + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Asynchronous core of the dispose pattern; sends a best-effort + /// Close for the underlying server handle when the + /// wrapper has not already been committed. Idempotent. + /// + /// + /// Implements the recommended pattern from + /// . + /// The class is sealed, so this helper is private + /// rather than the protected virtual shape recommended + /// for unsealed types. + /// + private async ValueTask DisposeAsyncCore() + { + if (m_terminated) + { + return; + } + await m_lock.WaitAsync().ConfigureAwait(false); + try + { + if (m_terminated) + { + return; + } + m_terminated = true; + try + { + await m_fileProxy.CloseAsync(m_handle, CancellationToken.None) + .ConfigureAwait(false); + } + catch (ServiceResultException) + { + // Best-effort: the server may roll back internally + // or the session may already be torn down. + } + m_inner.MarkDisposedWithoutClosing(); + } + finally + { + m_lock.Release(); + m_lock.Dispose(); + } + } + + /// + /// Synchronous fallback for callers that use using + /// instead of await using. Mirrors the + /// System.IDisposable dispose pattern; prefer the async + /// path to avoid a GetAwaiter().GetResult() hop. + /// + private void Dispose(bool disposing) + { + if (m_terminated || !disposing) + { + return; + } + try + { + DisposeAsyncCore().AsTask().GetAwaiter().GetResult(); + } + catch + { + // Best-effort: async path surfaces exceptions. + } + } + + private readonly TemporaryFileTransferTypeClient m_transferProxy; + private readonly FileTypeClient m_fileProxy; + // CA2213: m_inner is intentionally NOT disposed via the standard + // IDisposable pattern; ownership of the underlying server handle + // belongs to UaTemporaryWriteFile.CommitAsync / DisposeAsync. + // CA1816: see DisposeAsync below. +#pragma warning disable CA2213 + private readonly UaFileStream m_inner; +#pragma warning restore CA2213 + private readonly uint m_handle; + private readonly SemaphoreSlim m_lock = new(1, 1); + private NodeId m_completionStateMachine = NodeId.Null; + private bool m_terminated; + + private sealed class NonClosingStreamWrapper : Stream + { + public NonClosingStreamWrapper(UaFileStream inner) + { + m_inner = inner; + } + + public override bool CanRead => m_inner.CanRead; + public override bool CanWrite => m_inner.CanWrite; + public override bool CanSeek => m_inner.CanSeek; + public override long Length => m_inner.Length; + + public override long Position + { + get => m_inner.Position; + set => m_inner.Position = value; + } + + public override int Read(byte[] buffer, int offset, int count) + { + return m_inner.Read(buffer, offset, count); + } + + public override Task ReadAsync( + byte[] buffer, int offset, int count, CancellationToken ct) + { + return m_inner.ReadAsync(buffer, offset, count, ct); + } + +#if NETSTANDARD2_1_OR_GREATER || NET + public override ValueTask ReadAsync( + Memory buffer, CancellationToken cancellationToken = default) + { + return m_inner.ReadAsync(buffer, cancellationToken); + } +#endif + + public override void Write(byte[] buffer, int offset, int count) + { + m_inner.Write(buffer, offset, count); + } + + public override Task WriteAsync( + byte[] buffer, int offset, int count, CancellationToken ct) + { + return m_inner.WriteAsync(buffer, offset, count, ct); + } + +#if NETSTANDARD2_1_OR_GREATER || NET + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + return m_inner.WriteAsync(buffer, cancellationToken); + } +#endif + + public override long Seek(long offset, SeekOrigin origin) + { + return m_inner.Seek(offset, origin); + } + + public override void SetLength(long value) + { + m_inner.SetLength(value); + } + + public override void Flush() + { + m_inner.Flush(); + } + + public override Task FlushAsync(CancellationToken ct) + { + return m_inner.FlushAsync(ct); + } + + /// + /// CA2215: Disposal of the inner stream (and the server file + /// handle it owns) is intentionally suppressed here — + /// UaTemporaryWriteFile.CommitAsync / DisposeAsync owns the + /// close lifecycle. We still call base.Dispose(disposing) to + /// satisfy the analyzer's contract. + /// + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + } + + private readonly UaFileStream m_inner; + } + } +} diff --git a/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientCrudTests.cs b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientCrudTests.cs new file mode 100644 index 0000000000..5267fc74fb --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientCrudTests.cs @@ -0,0 +1,361 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Client.FileSystem; + +namespace Opc.Ua.Client.Tests.FileSystem +{ + /// + /// End-to-end mock-based tests for the CRUD surface of + /// + /// (CreateDirectoryAsync/CreateFileAsync/ + /// DeleteAsync/MoveAsync/CopyAsync). + /// + [TestFixture] + [Category("FileSystem")] + [Parallelizable] + public class FileSystemClientCrudTests + { + [Test] + public async Task CreateDirectoryAsyncIssuesCallWithCorrectMethodIdAndArgsAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + ScriptCreateDirectory(harness, new NodeId(7001)); + var client = new FileSystemClient(harness.Session, harness.Root); + + UaDirectoryInfo dir = await client.CreateDirectoryAsync("Reports").ConfigureAwait(false); + + CallMethodRequest req = SingleCallTo( + harness, Methods.FileDirectoryType_CreateDirectory); + Assert.That(req.ObjectId, Is.EqualTo(harness.Root)); + req.InputArguments[0].TryGetValue(out string dirName); + Assert.That(dirName, Is.EqualTo("Reports")); + Assert.That(dir.NodeId, Is.EqualTo(new NodeId(7001))); + } + + [Test] + public async Task CreateFileAsyncIssuesCallWithRequestFileOpenFalseAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + ScriptCreateFile(harness, new NodeId(7002)); + var client = new FileSystemClient(harness.Session, harness.Root); + + UaFileInfo file = await client.CreateFileAsync("data.bin").ConfigureAwait(false); + + CallMethodRequest req = SingleCallTo(harness, Methods.FileDirectoryType_CreateFile); + req.InputArguments[0].TryGetValue(out string fileName); + req.InputArguments[1].TryGetValue(out bool requestFileOpen); + Assert.That(fileName, Is.EqualTo("data.bin")); + Assert.That(requestFileOpen, Is.False, + "Server-allocated handle must never leak through CreateFileAsync."); + Assert.That(file.NodeId, Is.EqualTo(new NodeId(7002))); + } + + [Test] + public async Task CreateDirectoryAsyncCreatesIntermediateDirectoriesAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + // Each CreateDirectory call yields a fresh NodeId per call. + int counter = 0; + harness.CallHandler = req => + { + if (req.MethodId.TryGetValue(out uint mid) && + mid == Methods.FileDirectoryType_CreateDirectory) + { + counter++; + req.InputArguments[0].TryGetValue(out string newName); + var newId = new NodeId((uint)(8000 + counter)); + harness.RegisterDirectory(req.ObjectId, new QualifiedName(newName), newId); + return new CallMethodResult + { + StatusCode = StatusCodes.Good, + OutputArguments = new[] { new Variant(newId) }.ToArrayOf() + }; + } + return new CallMethodResult { StatusCode = StatusCodes.Good }; + }; + var client = new FileSystemClient(harness.Session, harness.Root); + + UaDirectoryInfo dir = await client + .CreateDirectoryAsync("/a/b/c", createIntermediate: true) + .ConfigureAwait(false); + + Assert.That(counter, Is.EqualTo(3)); + Assert.That(dir.FullPath, Is.EqualTo("/a/b/c")); + } + + [Test] + public async Task CreateDirectoryAsyncThrowsWhenIntermediateMissingAndFlagFalseAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + var client = new FileSystemClient(harness.Session, harness.Root); + + Assert.ThrowsAsync( + async () => await client + .CreateDirectoryAsync("/a/b/c", createIntermediate: false) + .ConfigureAwait(false)); + } + + [Test] + public async Task CreateDirectoryAsyncRejectsNamespacePrefixedLeafAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + var client = new FileSystemClient(harness.Session, harness.Root); + + Assert.ThrowsAsync( + async () => await client + .CreateDirectoryAsync("/1:Reports") + .ConfigureAwait(false)); + } + + [Test] + public async Task DeleteAsyncOnFileIssuesCallOnParentAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + NodeId fileId = harness.RegisterFile(harness.Root, new QualifiedName("data.bin")); + var client = new FileSystemClient(harness.Session, harness.Root); + + await client.DeleteAsync("/data.bin").ConfigureAwait(false); + + CallMethodRequest req = SingleCallTo( + harness, Methods.FileDirectoryType_DeleteFileSystemObject); + Assert.That(req.ObjectId, Is.EqualTo(harness.Root)); + req.InputArguments[0].TryGetValue(out NodeId toDelete); + Assert.That(toDelete, Is.EqualTo(fileId)); + } + + [Test] + public async Task DeleteAsyncOnEmptyDirectoryWithoutRecursiveCallsServerOnceAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + harness.RegisterDirectory(harness.Root, new QualifiedName("subdir")); + var client = new FileSystemClient(harness.Session, harness.Root); + + await client.DeleteAsync("/subdir", recursive: false).ConfigureAwait(false); + + Assert.That(harness.CallRequests, Has.Count.EqualTo(1)); + } + + [Test] + public async Task DeleteAsyncOnNonEmptyDirectoryWithoutRecursiveThrowsAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + NodeId subdir = harness.RegisterDirectory(harness.Root, new QualifiedName("subdir")); + harness.RegisterFile(subdir, new QualifiedName("file.txt")); + var client = new FileSystemClient(harness.Session, harness.Root); + + Assert.ThrowsAsync( + async () => await client + .DeleteAsync("/subdir", recursive: false) + .ConfigureAwait(false)); + // No Delete call should have been issued. + Assert.That(harness.CallRequests.Any(r => + r.MethodId.TryGetValue(out uint mid) && + mid == Methods.FileDirectoryType_DeleteFileSystemObject), Is.False); + } + + [Test] + public async Task DeleteAsyncOnNonEmptyDirectoryWithRecursiveCallsServerOnceAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + NodeId subdir = harness.RegisterDirectory(harness.Root, new QualifiedName("subdir")); + harness.RegisterFile(subdir, new QualifiedName("file.txt")); + var client = new FileSystemClient(harness.Session, harness.Root); + + await client.DeleteAsync("/subdir", recursive: true).ConfigureAwait(false); + + List deletes = harness.CallRequests + .Where(r => r.MethodId.TryGetValue(out uint mid) && + mid == Methods.FileDirectoryType_DeleteFileSystemObject) + .ToList(); + // Exactly one Delete call — server is responsible for + // recursive traversal (Part 20 §4.3.2). + Assert.That(deletes, Has.Count.EqualTo(1)); + deletes[0].InputArguments[0].TryGetValue(out NodeId toDelete); + Assert.That(toDelete, Is.EqualTo(subdir)); + } + + [Test] + public async Task MoveAsyncIssuesMoveOrCopyOnSourceParentAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + NodeId srcDir = harness.RegisterDirectory(harness.Root, new QualifiedName("src")); + NodeId fileId = harness.RegisterFile(srcDir, new QualifiedName("data.bin")); + NodeId destDir = harness.RegisterDirectory(harness.Root, new QualifiedName("dest")); + ScriptMoveOrCopy(harness, new NodeId(9001)); + var client = new FileSystemClient(harness.Session, harness.Root); + + UaFileSystemInfo moved = await client + .MoveAsync("/src/data.bin", "/dest/data.bin") + .ConfigureAwait(false); + + CallMethodRequest req = SingleCallTo(harness, Methods.FileDirectoryType_MoveOrCopy); + Assert.That(req.ObjectId, Is.EqualTo(srcDir), "MoveOrCopy must be invoked on the source's parent directory."); + req.InputArguments[0].TryGetValue(out NodeId objToMove); + req.InputArguments[1].TryGetValue(out NodeId targetDirectory); + req.InputArguments[2].TryGetValue(out bool createCopy); + req.InputArguments[3].TryGetValue(out string newName); + Assert.That(objToMove, Is.EqualTo(fileId)); + Assert.That(targetDirectory, Is.EqualTo(destDir)); + Assert.That(createCopy, Is.False); + Assert.That(newName, Is.EqualTo("data.bin")); + Assert.That(moved.NodeId, Is.EqualTo(new NodeId(9001))); + } + + [Test] + public async Task CopyAsyncIssuesMoveOrCopyWithCreateCopyTrueAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + NodeId srcDir = harness.RegisterDirectory(harness.Root, new QualifiedName("src")); + harness.RegisterFile(srcDir, new QualifiedName("data.bin")); + harness.RegisterDirectory(harness.Root, new QualifiedName("dest")); + ScriptMoveOrCopy(harness, new NodeId(9002)); + var client = new FileSystemClient(harness.Session, harness.Root); + + _ = await client + .CopyAsync("/src/data.bin", "/dest/copy.bin") + .ConfigureAwait(false); + + CallMethodRequest req = SingleCallTo(harness, Methods.FileDirectoryType_MoveOrCopy); + req.InputArguments[2].TryGetValue(out bool createCopy); + req.InputArguments[3].TryGetValue(out string newName); + Assert.That(createCopy, Is.True); + Assert.That(newName, Is.EqualTo("copy.bin")); + } + + [Test] + public async Task DeleteAsyncMapsBadUserAccessDeniedAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + harness.RegisterFile(harness.Root, new QualifiedName("locked.bin")); + harness.CallHandler = req => + { + if (req.MethodId.TryGetValue(out uint mid) && + mid == Methods.FileDirectoryType_DeleteFileSystemObject) + { + return new CallMethodResult + { + StatusCode = StatusCodes.BadUserAccessDenied, + OutputArguments = Array.Empty().ToArrayOf() + }; + } + return new CallMethodResult { StatusCode = StatusCodes.Good }; + }; + var client = new FileSystemClient(harness.Session, harness.Root); + + Assert.ThrowsAsync( + async () => await client.DeleteAsync("/locked.bin").ConfigureAwait(false)); + } + + // -------- helpers ------------------------------------------------ + + private static void ScriptCreateDirectory( + FileSystemSessionHarness harness, NodeId newId) + { + harness.CallHandler = req => + { + if (req.MethodId.TryGetValue(out uint mid) && + mid == Methods.FileDirectoryType_CreateDirectory) + { + req.InputArguments[0].TryGetValue(out string newName); + harness.RegisterDirectory( + req.ObjectId, new QualifiedName(newName), newId); + return new CallMethodResult + { + StatusCode = StatusCodes.Good, + OutputArguments = new[] { new Variant(newId) }.ToArrayOf() + }; + } + return new CallMethodResult { StatusCode = StatusCodes.Good }; + }; + } + + private static void ScriptCreateFile( + FileSystemSessionHarness harness, NodeId newId) + { + harness.CallHandler = req => + { + if (req.MethodId.TryGetValue(out uint mid) && + mid == Methods.FileDirectoryType_CreateFile) + { + req.InputArguments[0].TryGetValue(out string newName); + harness.RegisterFile(req.ObjectId, new QualifiedName(newName), newId); + return new CallMethodResult + { + StatusCode = StatusCodes.Good, + OutputArguments = new[] + { + new Variant(newId), + new Variant(0u) + }.ToArrayOf() + }; + } + return new CallMethodResult { StatusCode = StatusCodes.Good }; + }; + } + + private static void ScriptMoveOrCopy( + FileSystemSessionHarness harness, NodeId resultNodeId) + { + harness.CallHandler = req => + { + if (req.MethodId.TryGetValue(out uint mid) && + mid == Methods.FileDirectoryType_MoveOrCopy) + { + req.InputArguments[3].TryGetValue(out string newName); + req.InputArguments[1].TryGetValue(out NodeId destDir); + harness.RegisterFile(destDir, new QualifiedName(newName), resultNodeId); + return new CallMethodResult + { + StatusCode = StatusCodes.Good, + OutputArguments = new[] { new Variant(resultNodeId) }.ToArrayOf() + }; + } + return new CallMethodResult { StatusCode = StatusCodes.Good }; + }; + } + + private static CallMethodRequest SingleCallTo( + FileSystemSessionHarness harness, uint methodId) + { + List matches = harness.CallRequests + .Where(r => r.MethodId.TryGetValue(out uint mid) && mid == methodId) + .ToList(); + Assert.That(matches, Has.Count.EqualTo(1), + $"Expected exactly one call to method {methodId}."); + return matches[0]; + } + } +} diff --git a/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientEnumerationTests.cs b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientEnumerationTests.cs new file mode 100644 index 0000000000..15e87e131f --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientEnumerationTests.cs @@ -0,0 +1,158 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +// CA1861: Test assertions intentionally compare against literal +// expected-value arrays. Lifting them to static readonly fields would +// be noise for one-element vectors used by a handful of tests. +#pragma warning disable CA1861 + +using System.Collections.Generic; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Client.FileSystem; + +namespace Opc.Ua.Client.Tests.FileSystem +{ + /// + /// End-to-end mock-based tests for + /// and the + /// / + /// / + /// + /// wrappers. + /// + [TestFixture] + [Category("FileSystem")] + [Parallelizable] + public class FileSystemClientEnumerationTests + { + [Test] + public async Task EnumerateAsyncReturnsBothFilesAndDirectoriesAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + harness.RegisterFile(harness.Root, new QualifiedName("a.txt")); + harness.RegisterDirectory(harness.Root, new QualifiedName("subdir")); + harness.RegisterFile(harness.Root, new QualifiedName("b.bin")); + var client = new FileSystemClient(harness.Session, harness.Root); + + var seen = new List(); + await foreach (UaFileSystemInfo entry in client.EnumerateAsync().ConfigureAwait(false)) + { + seen.Add($"{(entry.IsDirectory ? "DIR" : "FILE")}:{entry.Name}"); + } + Assert.That(seen, Has.Count.EqualTo(3)); + Assert.That(seen, Does.Contain("FILE:a.txt")); + Assert.That(seen, Does.Contain("DIR:subdir")); + Assert.That(seen, Does.Contain("FILE:b.bin")); + } + + [Test] + public async Task EnumerateFilesAsyncFiltersToFilesOnlyAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + harness.RegisterFile(harness.Root, new QualifiedName("a.txt")); + harness.RegisterDirectory(harness.Root, new QualifiedName("subdir")); + var client = new FileSystemClient(harness.Session, harness.Root); + + var files = new List(); + await foreach (UaFileInfo file in client.EnumerateFilesAsync().ConfigureAwait(false)) + { + files.Add(file.Name); + } + Assert.That(files, Is.EqualTo(new[] { "a.txt" })); + } + + [Test] + public async Task EnumerateDirectoriesAsyncFiltersToDirectoriesOnlyAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + harness.RegisterFile(harness.Root, new QualifiedName("a.txt")); + harness.RegisterDirectory(harness.Root, new QualifiedName("subdir")); + var client = new FileSystemClient(harness.Session, harness.Root); + + var directories = new List(); + await foreach (UaDirectoryInfo dir in client + .EnumerateDirectoriesAsync().ConfigureAwait(false)) + { + directories.Add(dir.Name); + } + Assert.That(directories, Is.EqualTo(new[] { "subdir" })); + } + + [Test] + public async Task EnumerateAsyncSkipsUnknownObjectTypesAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + harness.RegisterFile(harness.Root, new QualifiedName("a.txt")); + // A child that is neither a FileType nor a FileDirectoryType + // should be filtered out. + harness.RegisterObject(harness.Root, new QualifiedName("Other"), + ObjectTypeIds.BaseObjectType); + var client = new FileSystemClient(harness.Session, harness.Root); + + var seen = new List(); + await foreach (UaFileSystemInfo entry in client.EnumerateAsync().ConfigureAwait(false)) + { + seen.Add(entry.Name); + } + Assert.That(seen, Is.EqualTo(new[] { "a.txt" })); + } + + [Test] + public async Task EnumerateAsyncOnEmptyDirectoryYieldsNothingAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + NodeId empty = harness.RegisterDirectory(harness.Root, new QualifiedName("empty")); + var client = new FileSystemClient(harness.Session, harness.Root); + + int count = 0; + await foreach (UaFileSystemInfo _ in client.EnumerateAsync("/empty") + .ConfigureAwait(false)) + { + count++; + } + Assert.That(count, Is.Zero); + } + + [Test] + public async Task EnumerateAsyncPropagatesFullPathAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + NodeId sub = harness.RegisterDirectory(harness.Root, new QualifiedName("Reports")); + harness.RegisterFile(sub, new QualifiedName("data.csv")); + var client = new FileSystemClient(harness.Session, harness.Root); + + await foreach (UaFileInfo file in client.EnumerateFilesAsync("/Reports") + .ConfigureAwait(false)) + { + Assert.That(file.FullPath, Is.EqualTo("/Reports/data.csv")); + } + } + } +} diff --git a/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientMetadataTests.cs b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientMetadataTests.cs new file mode 100644 index 0000000000..00d076399e --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientMetadataTests.cs @@ -0,0 +1,124 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Client.FileSystem; + +namespace Opc.Ua.Client.Tests.FileSystem +{ + /// + /// End-to-end mock-based tests for + /// (the seven well-known FileType properties). + /// + [TestFixture] + [Category("FileSystem")] + [Parallelizable] + public class FileSystemClientMetadataTests + { + [Test] + public async Task RefreshAsyncPopulatesMandatoryPropertiesAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + var props = new FileProperties + { + Size = 12_345UL, + Writable = true, + UserWritable = false, + OpenCount = (ushort)3 + }; + props.Realize(); + harness.RegisterFile(harness.Root, new QualifiedName("data.bin"), properties: props); + var client = new FileSystemClient(harness.Session, harness.Root); + + UaFileInfo file = await client.GetFileAsync("/data.bin").ConfigureAwait(false); + await file.RefreshAsync().ConfigureAwait(false); + + Assert.That(file.Size, Is.EqualTo(12_345UL)); + Assert.That(file.Writable, Is.True); + Assert.That(file.UserWritable, Is.False); + Assert.That(file.OpenCount, Is.EqualTo(3)); + Assert.That(file.MimeType, Is.Null); + Assert.That(file.MaxByteStringLength, Is.Null); + Assert.That(file.LastModifiedTime, Is.Null); + } + + [Test] + public async Task RefreshAsyncPopulatesOptionalPropertiesAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + DateTime expectedModified = new DateTime(2024, 5, 12, 13, 0, 0, DateTimeKind.Utc); + var props = new FileProperties + { + Size = 0UL, + Writable = true, + UserWritable = true, + OpenCount = (ushort)0, + MimeType = "application/json", + MaxByteStringLength = 64_000u, + LastModifiedTime = expectedModified + }; + props.Realize(); + harness.RegisterFile(harness.Root, new QualifiedName("data.json"), properties: props); + var client = new FileSystemClient(harness.Session, harness.Root); + + UaFileInfo file = await client.GetFileAsync("/data.json").ConfigureAwait(false); + await file.RefreshAsync().ConfigureAwait(false); + + Assert.That(file.MimeType, Is.EqualTo("application/json")); + Assert.That(file.MaxByteStringLength, Is.EqualTo(64_000u)); + Assert.That(file.LastModifiedTime, Is.EqualTo(expectedModified)); + } + + [Test] + public async Task RefreshAsyncToleratesMissingOptionalPropertiesAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + // No properties realised — the harness will return BadNoMatch + // for every property lookup. + harness.RegisterFile(harness.Root, new QualifiedName("data.bin"), + properties: new FileProperties()); + var client = new FileSystemClient(harness.Session, harness.Root); + + UaFileInfo file = await client.GetFileAsync("/data.bin").ConfigureAwait(false); + await file.RefreshAsync().ConfigureAwait(false); + + // Defaults for non-resolved mandatory properties. + Assert.That(file.Size, Is.Zero); + Assert.That(file.Writable, Is.False); + Assert.That(file.UserWritable, Is.False); + Assert.That(file.OpenCount, Is.Zero); + // Optional properties remain null. + Assert.That(file.MimeType, Is.Null); + Assert.That(file.MaxByteStringLength, Is.Null); + Assert.That(file.LastModifiedTime, Is.Null); + } + } +} diff --git a/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientOptionsTests.cs b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientOptionsTests.cs new file mode 100644 index 0000000000..9e536c132b --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientOptionsTests.cs @@ -0,0 +1,104 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using NUnit.Framework; +using Opc.Ua.Client.FileSystem; + +namespace Opc.Ua.Client.Tests.FileSystem +{ + /// + /// Unit tests for . + /// + [TestFixture] + [Category("FileSystem")] + [Parallelizable] + public class FileSystemClientOptionsTests + { + [Test] + public void DefaultsAreSensible() + { + var options = new FileSystemClientOptions(); + Assert.That(options.ChunkSize, Is.EqualTo(64 * 1024)); + Assert.That(options.MaxBufferedReadSize, Is.EqualTo(16L * 1024 * 1024)); + Assert.That(options.PathCacheSize, Is.EqualTo(1024)); + Assert.That(options.IncludeFileTypeSubtypes, Is.True); + Assert.That(options.IncludeFileDirectoryTypeSubtypes, Is.True); + } + + [Test] + public void CloneProducesIndependentInstance() + { + var original = new FileSystemClientOptions + { + ChunkSize = 1234, + MaxBufferedReadSize = 5678, + PathCacheSize = 7, + IncludeFileTypeSubtypes = false, + IncludeFileDirectoryTypeSubtypes = false + }; + + FileSystemClientOptions clone = original.Clone(); + clone.ChunkSize = 9999; + + Assert.That(original.ChunkSize, Is.EqualTo(1234)); + Assert.That(clone.MaxBufferedReadSize, Is.EqualTo(5678)); + Assert.That(clone.PathCacheSize, Is.EqualTo(7)); + Assert.That(clone.IncludeFileTypeSubtypes, Is.False); + Assert.That(clone.IncludeFileDirectoryTypeSubtypes, Is.False); + } + + [Test] + public void ValidateRejectsZeroChunkSize() + { + var options = new FileSystemClientOptions { ChunkSize = 0 }; + Assert.Throws(options.Validate); + } + + [Test] + public void ValidateRejectsNegativePathCacheSize() + { + var options = new FileSystemClientOptions { PathCacheSize = -1 }; + Assert.Throws(options.Validate); + } + + [Test] + public void ValidateRejectsZeroMaxBufferedReadSize() + { + var options = new FileSystemClientOptions { MaxBufferedReadSize = 0 }; + Assert.Throws(options.Validate); + } + + [Test] + public void ValidatePassesForDefaults() + { + new FileSystemClientOptions().Validate(); + } + } +} diff --git a/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientPathResolutionTests.cs b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientPathResolutionTests.cs new file mode 100644 index 0000000000..23fdecee2a --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientPathResolutionTests.cs @@ -0,0 +1,220 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.Client.FileSystem; + +namespace Opc.Ua.Client.Tests.FileSystem +{ + /// + /// End-to-end mock-based tests for the path-resolution surface of + /// + /// (GetInfoAsync/GetFileAsync/GetDirectoryAsync/ + /// ExistsAsync). + /// + [TestFixture] + [Category("FileSystem")] + [Parallelizable] + public class FileSystemClientPathResolutionTests + { + [Test] + public async Task GetInfoAsyncReturnsRootForEmptyPathAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + var client = new FileSystemClient(harness.Session, harness.Root); + + UaFileSystemInfo info = await client.GetInfoAsync(string.Empty) + .ConfigureAwait(false); + Assert.That(info, Is.SameAs(client.Root)); + + UaFileSystemInfo infoRoot = await client.GetInfoAsync("/") + .ConfigureAwait(false); + Assert.That(infoRoot, Is.SameAs(client.Root)); + } + + [Test] + public async Task GetFileAsyncResolvesTwoSegmentPathAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + NodeId reports = harness.RegisterDirectory( + harness.Root, new QualifiedName("Reports")); + NodeId fileId = harness.RegisterFile( + reports, new QualifiedName("data.csv")); + var client = new FileSystemClient(harness.Session, harness.Root); + + UaFileInfo file = await client.GetFileAsync("Reports/data.csv") + .ConfigureAwait(false); + Assert.That(file.NodeId, Is.EqualTo(fileId)); + Assert.That(file.Name, Is.EqualTo("data.csv")); + Assert.That(file.FullPath, Is.EqualTo("/Reports/data.csv")); + } + + [Test] + public async Task GetDirectoryAsyncResolvesQualifiedSegmentsAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + NodeId child = harness.RegisterDirectory( + harness.Root, new QualifiedName("Reports", 1)); + var client = new FileSystemClient(harness.Session, harness.Root); + + UaDirectoryInfo dir = await client.GetDirectoryAsync("1:Reports") + .ConfigureAwait(false); + Assert.That(dir.NodeId, Is.EqualTo(child)); + Assert.That(dir.BrowseName.NamespaceIndex, Is.EqualTo(1)); + Assert.That(dir.FullPath, Is.EqualTo("/1:Reports")); + } + + [Test] + public async Task GetInfoAsyncReturnsNullForMissingPathAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + var client = new FileSystemClient(harness.Session, harness.Root); + + UaFileSystemInfo info = await client.GetInfoAsync("/missing/path") + .ConfigureAwait(false); + Assert.That(info, Is.Null); + } + + [Test] + public async Task GetFileAsyncThrowsFileNotFoundForMissingPathAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + var client = new FileSystemClient(harness.Session, harness.Root); + + Assert.ThrowsAsync( + async () => await client.GetFileAsync("/missing.txt") + .ConfigureAwait(false)); + } + + [Test] + public async Task GetDirectoryAsyncThrowsDirectoryNotFoundForMissingPathAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + var client = new FileSystemClient(harness.Session, harness.Root); + + Assert.ThrowsAsync( + async () => await client.GetDirectoryAsync("/missing") + .ConfigureAwait(false)); + } + + [Test] + public async Task GetFileAsyncThrowsWhenPathResolvesToDirectoryAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + harness.RegisterDirectory(harness.Root, new QualifiedName("Reports")); + var client = new FileSystemClient(harness.Session, harness.Root); + + Assert.ThrowsAsync( + async () => await client.GetFileAsync("/Reports") + .ConfigureAwait(false)); + } + + [Test] + public async Task GetDirectoryAsyncThrowsWhenPathResolvesToFileAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + harness.RegisterFile(harness.Root, new QualifiedName("data.csv")); + var client = new FileSystemClient(harness.Session, harness.Root); + + Assert.ThrowsAsync( + async () => await client.GetDirectoryAsync("/data.csv") + .ConfigureAwait(false)); + } + + [Test] + public async Task ExistsAsyncReturnsTrueForExistingPathAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + harness.RegisterFile(harness.Root, new QualifiedName("data.csv")); + var client = new FileSystemClient(harness.Session, harness.Root); + + Assert.That(await client.ExistsAsync("/data.csv").ConfigureAwait(false), Is.True); + Assert.That(await client.FileExistsAsync("/data.csv").ConfigureAwait(false), Is.True); + Assert.That(await client.DirectoryExistsAsync("/data.csv").ConfigureAwait(false), + Is.False); + } + + [Test] + public async Task ExistsAsyncReturnsFalseForMissingPathAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + var client = new FileSystemClient(harness.Session, harness.Root); + + Assert.That(await client.ExistsAsync("/missing").ConfigureAwait(false), Is.False); + Assert.That(await client.FileExistsAsync("/missing").ConfigureAwait(false), Is.False); + Assert.That(await client.DirectoryExistsAsync("/missing").ConfigureAwait(false), + Is.False); + } + + [Test] + public async Task ResolvedPathIsCachedAcrossLookupsAsync() + { + FileSystemSessionHarness harness = FileSystemSessionHarness.Create(); + harness.RegisterFile(harness.Root, new QualifiedName("data.csv")); + var client = new FileSystemClient(harness.Session, harness.Root); + + int translateCalls = 0; + harness.SessionMock + .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, System.Threading.CancellationToken>( + (header, paths, ct) => + { + translateCalls++; + BrowsePathResult[] results = new BrowsePathResult[paths.Count]; + for (int i = 0; i < paths.Count; i++) + { + results[i] = harness.ResolveBrowsePathForTest(paths[i]); + } + var response = new TranslateBrowsePathsToNodeIdsResponse + { + ResponseHeader = new ResponseHeader(), + Results = results.ToArrayOf(), + DiagnosticInfos = default + }; + return new ValueTask(response); + }); + + _ = await client.GetFileAsync("/data.csv").ConfigureAwait(false); + int firstCount = translateCalls; + _ = await client.GetFileAsync("/data.csv").ConfigureAwait(false); + + // The second resolution should be served from the path + // cache and not call Translate again for the leaf segment. + Assert.That(translateCalls, Is.EqualTo(firstCount)); + } + } +} diff --git a/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemErrorsTests.cs b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemErrorsTests.cs new file mode 100644 index 0000000000..c9df4f27af --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemErrorsTests.cs @@ -0,0 +1,157 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.IO; +using NUnit.Framework; +using Opc.Ua.Client.FileSystem; + +namespace Opc.Ua.Client.Tests.FileSystem +{ + /// + /// Unit tests for the internal FileSystemErrors mapper. + /// + [TestFixture] + [Category("FileSystem")] + [Parallelizable] + public class FileSystemErrorsTests + { + [Test] + public void BadNoMatchOnDirectoryReturnsDirectoryNotFoundException() + { + var ex = new ServiceResultException(StatusCodes.BadNoMatch); + Exception mapped = FileSystemErrors.Translate(ex, "/foo/bar", targetIsDirectory: true); + Assert.That(mapped, Is.InstanceOf()); + Assert.That(mapped.Message, Does.Contain("/foo/bar")); + Assert.That(mapped.InnerException, Is.SameAs(ex)); + } + + [Test] + public void BadNoMatchOnFileReturnsFileNotFoundException() + { + var ex = new ServiceResultException(StatusCodes.BadNoMatch); + Exception mapped = FileSystemErrors.Translate(ex, "/foo/bar.txt", targetIsDirectory: false); + Assert.That(mapped, Is.InstanceOf()); + Assert.That(((FileNotFoundException)mapped).FileName, Is.EqualTo("/foo/bar.txt")); + } + + [Test] + public void BadNotFoundMapsToNotFound() + { + var ex = new ServiceResultException(StatusCodes.BadNotFound); + Exception mapped = FileSystemErrors.Translate(ex, "/x", targetIsDirectory: false); + Assert.That(mapped, Is.InstanceOf()); + } + + [Test] + public void BadNodeIdUnknownMapsToNotFound() + { + var ex = new ServiceResultException(StatusCodes.BadNodeIdUnknown); + Exception mapped = FileSystemErrors.Translate(ex, "/x", targetIsDirectory: true); + Assert.That(mapped, Is.InstanceOf()); + } + + [Test] + public void BadBrowseNameDuplicatedMapsToIOException() + { + var ex = new ServiceResultException(StatusCodes.BadBrowseNameDuplicated); + Exception mapped = FileSystemErrors.Translate(ex, "/foo", targetIsDirectory: false); + Assert.That(mapped, Is.InstanceOf()); + Assert.That(mapped.Message, Does.Contain("already exists")); + } + + [Test] + public void BadUserAccessDeniedMapsToUnauthorizedAccessException() + { + var ex = new ServiceResultException(StatusCodes.BadUserAccessDenied); + Exception mapped = FileSystemErrors.Translate(ex, "/foo", targetIsDirectory: false); + Assert.That(mapped, Is.InstanceOf()); + Assert.That(mapped.Message, Does.Contain("access denied")); + } + + [Test] + public void BadNotWritableMapsToUnauthorizedAccessException() + { + var ex = new ServiceResultException(StatusCodes.BadNotWritable); + Exception mapped = FileSystemErrors.Translate(ex, "/foo", targetIsDirectory: false); + Assert.That(mapped, Is.InstanceOf()); + } + + [Test] + public void BadResourceUnavailableMapsToIOExceptionNotUnauthorized() + { + var ex = new ServiceResultException(StatusCodes.BadResourceUnavailable); + Exception mapped = FileSystemErrors.Translate(ex, "/foo", targetIsDirectory: false); + Assert.That(mapped, Is.InstanceOf()); + Assert.That(mapped, Is.Not.InstanceOf()); + } + + [Test] + public void BadInvalidStateMapsToIOException() + { + var ex = new ServiceResultException(StatusCodes.BadInvalidState); + Exception mapped = FileSystemErrors.Translate(ex, "/foo", targetIsDirectory: false); + Assert.That(mapped, Is.InstanceOf()); + } + + [Test] + public void UnmappedStatusCodePassesThroughOriginalException() + { + var ex = new ServiceResultException(StatusCodes.BadInternalError); + Exception mapped = FileSystemErrors.Translate(ex, "/foo", targetIsDirectory: false); + Assert.That(mapped, Is.SameAs(ex)); + } + + [Test] + public void NotFoundFactoryReturnsCorrectExceptionType() + { + Exception fileException = FileSystemErrors.NotFound("/x.txt", targetIsDirectory: false); + Exception dirException = FileSystemErrors.NotFound("/x", targetIsDirectory: true); + Assert.That(fileException, Is.InstanceOf()); + Assert.That(((FileNotFoundException)fileException).FileName, Is.EqualTo("/x.txt")); + Assert.That(dirException, Is.InstanceOf()); + } + + [Test] + public void AmbiguousFactoryReturnsIOExceptionWithCount() + { + IOException ex = FileSystemErrors.Ambiguous("/foo", 3); + Assert.That(ex.Message, Does.Contain("ambiguous")); + Assert.That(ex.Message, Does.Contain("3")); + Assert.That(ex.Message, Does.Contain("/foo")); + } + + [Test] + public void TranslateNullExceptionThrowsArgumentNullException() + { + Assert.Throws( + () => FileSystemErrors.Translate(null!, "/x", false)); + } + } +} diff --git a/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemSessionHarness.cs b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemSessionHarness.cs new file mode 100644 index 0000000000..befc852bf2 --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemSessionHarness.cs @@ -0,0 +1,525 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Opc.Ua.Tests; + +namespace Opc.Ua.Client.Tests.FileSystem +{ + /// + /// Scriptable in-memory mock of an OPC UA + /// that exposes a tiny FileType / FileDirectoryType address space + /// and answers TranslateBrowsePathsToNodeIds / + /// Browse / Read / Call requests against it. + /// Used by the high-level FileSystemClient tests instead of + /// a live server. + /// + /// + /// The fake address space is a plain Dictionary graph: + /// each registered node carries a , + /// , type definition (FileType or + /// FileDirectoryType), and (optionally) a property bag for + /// FileType metadata (Size, Writable, MimeType, …). Hierarchical + /// relationships are tracked per parent in + /// . + /// + internal sealed class FileSystemSessionHarness + { + public Mock SessionMock { get; } + public ISession Session => SessionMock.Object; + public IServiceMessageContext MessageContext { get; } + + public NodeId Root { get; } + public Dictionary Nodes { get; } = []; + public Dictionary> ChildrenOf { get; } = []; + + public List CallRequests { get; } = []; + public Func CallHandler { get; set; } + + private FileSystemSessionHarness( + Mock mock, + IServiceMessageContext messageContext, + NodeId rootId) + { + SessionMock = mock; + MessageContext = messageContext; + Root = rootId; + } + + public static FileSystemSessionHarness Create(NodeId rootId = default) + { + if (rootId.IsNull) + { + rootId = new NodeId(1000); + } + + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + IServiceMessageContext messageContext = ServiceMessageContext.Create(telemetry); + + var sessionMock = new Mock(MockBehavior.Loose); + sessionMock.SetupGet(s => s.MessageContext).Returns(messageContext); + + var typeTree = new Mock(MockBehavior.Loose); + // Subtype check: nothing is a subtype except equality (the + // harness's nodes use the exact base type NodeId). + typeTree + .Setup(t => t.IsTypeOf(It.IsAny(), It.IsAny())) + .Returns((sub, super) => sub.Equals(super)); + sessionMock.SetupGet(s => s.TypeTree).Returns(typeTree.Object); + sessionMock + .Setup(s => s.FetchTypeTreeAsync( + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var harness = new FileSystemSessionHarness(sessionMock, messageContext, rootId); + harness.RegisterNode(rootId, new QualifiedName("FileSystem"), + ObjectTypeIds.FileDirectoryType, isDirectory: true); + + // Wire up the per-service handlers. + sessionMock + .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, paths, _) => + { + var results = new BrowsePathResult[paths.Count]; + for (int i = 0; i < paths.Count; i++) + { + results[i] = harness.ResolveBrowsePath(paths[i]); + } + var response = new TranslateBrowsePathsToNodeIdsResponse + { + ResponseHeader = new ResponseHeader(), + Results = results.ToArrayOf(), + DiagnosticInfos = default + }; + return new ValueTask(response); + }); + + sessionMock + .Setup(s => s.BrowseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, + CancellationToken>( + (_, _, _, descriptions, _) => + { + var results = new BrowseResult[descriptions.Count]; + for (int i = 0; i < descriptions.Count; i++) + { + results[i] = harness.ResolveBrowse(descriptions[i]); + } + var response = new BrowseResponse + { + ResponseHeader = new ResponseHeader(), + Results = results.ToArrayOf(), + DiagnosticInfos = default + }; + return new ValueTask(response); + }); + + sessionMock + .Setup(s => s.ReadAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, + CancellationToken>( + (_, _, _, nodesToRead, _) => + { + var results = new DataValue[nodesToRead.Count]; + for (int i = 0; i < nodesToRead.Count; i++) + { + results[i] = harness.ResolveRead(nodesToRead[i]); + } + var response = new ReadResponse + { + ResponseHeader = new ResponseHeader(), + Results = results.ToArrayOf(), + DiagnosticInfos = default + }; + return new ValueTask(response); + }); + + sessionMock + .Setup(s => s.CallAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, methodsToCall, _) => + { + var results = new CallMethodResult[methodsToCall.Count]; + for (int i = 0; i < methodsToCall.Count; i++) + { + CallMethodRequest req = methodsToCall[i]; + harness.CallRequests.Add(req); + results[i] = harness.CallHandler != null + ? harness.CallHandler(req) + : new CallMethodResult + { + StatusCode = StatusCodes.Good, + OutputArguments = Array.Empty().ToArrayOf() + }; + } + var response = new CallResponse + { + ResponseHeader = new ResponseHeader(), + Results = results.ToArrayOf(), + DiagnosticInfos = default + }; + return new ValueTask(response); + }); + + return harness; + } + + public NodeId RegisterDirectory(NodeId parent, QualifiedName name, NodeId childId = default) + { + if (childId.IsNull) + { + childId = new NodeId((uint)s_nextId++); + } + RegisterNode(childId, name, ObjectTypeIds.FileDirectoryType, isDirectory: true); + LinkChild(parent, childId); + return childId; + } + + public NodeId RegisterFile( + NodeId parent, + QualifiedName name, + NodeId childId = default, + FileProperties properties = null) + { + if (childId.IsNull) + { + childId = new NodeId((uint)s_nextId++); + } + RegisterNode(childId, name, ObjectTypeIds.FileType, isDirectory: false, + properties: properties); + LinkChild(parent, childId); + return childId; + } + + /// + /// Registers a child object with an arbitrary type definition; + /// useful for tests that need to assert filtering behaviour. + /// + public NodeId RegisterObject( + NodeId parent, QualifiedName name, NodeId typeDef, NodeId childId = default) + { + if (childId.IsNull) + { + childId = new NodeId((uint)s_nextId++); + } + Nodes[childId] = new FakeNode(childId, name, typeDef, isDirectory: false); + LinkChild(parent, childId); + return childId; + } + + private void RegisterNode( + NodeId nodeId, + QualifiedName name, + NodeId typeDefinition, + bool isDirectory, + FileProperties properties = null) + { + Nodes[nodeId] = new FakeNode(nodeId, name, typeDefinition, isDirectory, properties); + } + + private void LinkChild(NodeId parent, NodeId child) + { + if (!ChildrenOf.TryGetValue(parent, out List list)) + { + list = []; + ChildrenOf[parent] = list; + } + list.Add(child); + } + + // ---- Request resolvers ----------------------------------------- + + /// + /// Test-only accessor exposing the harness's + /// ResolveBrowsePath logic so individual tests can wire + /// up their own Setup(...).Returns(...) handlers that + /// also delegate back into the in-memory state. + /// + public BrowsePathResult ResolveBrowsePathForTest(BrowsePath path) + { + return ResolveBrowsePath(path); + } + + private BrowsePathResult ResolveBrowsePath(BrowsePath path) + { + NodeId current = path.StartingNode; + foreach (RelativePathElement element in path.RelativePath.Elements) + { + NodeId match = NodeId.Null; + + // Look at child nodes first. + if (ChildrenOf.TryGetValue(current, out List children)) + { + foreach (NodeId childId in children) + { + FakeNode child = Nodes[childId]; + if (child.Name.Equals(element.TargetName)) + { + match = childId; + break; + } + } + } + + // Fall back to the property bag (FileType metadata). + if (match.IsNull && + Nodes.TryGetValue(current, out FakeNode owner) && + owner.Properties != null && + owner.Properties.TryGetProperty(element.TargetName, out NodeId propId)) + { + match = propId; + } + + if (match.IsNull) + { + return BadResult(StatusCodes.BadNoMatch); + } + current = match; + } + var target = new BrowsePathTarget + { + TargetId = current, + RemainingPathIndex = uint.MaxValue + }; + return new BrowsePathResult + { + StatusCode = StatusCodes.Good, + Targets = new[] { target }.ToArrayOf() + }; + } + + private static BrowsePathResult BadResult(StatusCode code) + { + return new BrowsePathResult + { + StatusCode = code, + Targets = Array.Empty().ToArrayOf() + }; + } + + private BrowseResult ResolveBrowse(BrowseDescription description) + { + NodeId source = description.NodeId; + var refs = new List(); + + // HasTypeDefinition browse — used by ReadTypeDefinitionAsync + // to classify a single object. + if (description.ReferenceTypeId.Equals(ReferenceTypeIds.HasTypeDefinition)) + { + if (Nodes.TryGetValue(source, out FakeNode node) && !node.TypeDefinition.IsNull) + { + refs.Add(new ReferenceDescription + { + ReferenceTypeId = ReferenceTypeIds.HasTypeDefinition, + IsForward = true, + NodeId = node.TypeDefinition, + NodeClass = NodeClass.ObjectType + }); + } + } + else + { + // Hierarchical browse — used by EnumerateChildrenAsync. + if (ChildrenOf.TryGetValue(source, out List children)) + { + foreach (NodeId childId in children) + { + FakeNode child = Nodes[childId]; + if (((uint)NodeClass.Object & description.NodeClassMask) != 0) + { + // Only emit Object-class children. + refs.Add(new ReferenceDescription + { + ReferenceTypeId = ReferenceTypeIds.Organizes, + IsForward = true, + NodeId = childId, + BrowseName = child.Name, + DisplayName = new LocalizedText(child.Name.Name), + NodeClass = NodeClass.Object, + TypeDefinition = child.TypeDefinition + }); + } + } + } + } + + return new BrowseResult + { + StatusCode = StatusCodes.Good, + ContinuationPoint = default, + References = refs.ToArrayOf() + }; + } + + private DataValue ResolveRead(ReadValueId rvi) + { + if (rvi.AttributeId == Attributes.BrowseName) + { + if (Nodes.TryGetValue(rvi.NodeId, out FakeNode node)) + { + return new DataValue(new Variant(node.Name)); + } + return new DataValue(StatusCodes.BadNodeIdUnknown); + } + if (rvi.AttributeId == Attributes.Value) + { + // For property NodeIds we look up the owning file and + // read the value off the FileProperties bag. + foreach (FakeNode candidate in Nodes.Values) + { + if (candidate.Properties == null) + { + continue; + } + Variant? value = candidate.Properties.TryGetPropertyValue(rvi.NodeId); + if (value != null) + { + return new DataValue(value.Value); + } + } + return new DataValue(StatusCodes.BadNodeIdUnknown); + } + return new DataValue(StatusCodes.BadAttributeIdInvalid); + } + + private static int s_nextId = 2000; + + internal sealed class FakeNode + { + public FakeNode( + NodeId id, + QualifiedName name, + NodeId typeDefinition, + bool isDirectory, + FileProperties properties = null) + { + Id = id; + Name = name; + TypeDefinition = typeDefinition; + IsDirectory = isDirectory; + Properties = properties; + } + + public NodeId Id { get; } + public QualifiedName Name { get; } + public NodeId TypeDefinition { get; } + public bool IsDirectory { get; } + public FileProperties Properties { get; } + } + } + + /// + /// Backing store for the seven well-known FileType properties used + /// by the FileSystem harness. Each property is identified by its + /// browse name in the standard UA namespace; the harness allocates + /// fresh NodeIds for them on demand. + /// + internal sealed class FileProperties + { + private readonly Dictionary m_propertyNodeIds = []; + private readonly Dictionary m_values = []; + private static int s_nextId = 5000; + + public ulong? Size { get; set; } + public bool? Writable { get; set; } + public bool? UserWritable { get; set; } + public ushort? OpenCount { get; set; } + public string MimeType { get; set; } + public uint? MaxByteStringLength { get; set; } + public DateTime? LastModifiedTime { get; set; } + + public void Realize() + { + BindProperty("Size", Size.HasValue ? new Variant(Size.Value) : default); + BindProperty("Writable", Writable.HasValue ? new Variant(Writable.Value) : default); + BindProperty("UserWritable", + UserWritable.HasValue ? new Variant(UserWritable.Value) : default); + BindProperty("OpenCount", + OpenCount.HasValue ? new Variant(OpenCount.Value) : default); + if (MimeType != null) + { + BindProperty("MimeType", new Variant(MimeType)); + } + if (MaxByteStringLength.HasValue) + { + BindProperty("MaxByteStringLength", + new Variant(MaxByteStringLength.Value)); + } + if (LastModifiedTime.HasValue) + { + BindProperty("LastModifiedTime", + new Variant(LastModifiedTime.Value)); + } + } + + public bool TryGetProperty(QualifiedName name, out NodeId nodeId) + { + return m_propertyNodeIds.TryGetValue(name, out nodeId); + } + + public Variant? TryGetPropertyValue(NodeId propertyNodeId) + { + if (m_values.TryGetValue(propertyNodeId, out Variant value)) + { + return value; + } + return null; + } + + private void BindProperty(string name, Variant value) + { + var qname = new QualifiedName(name); + var nodeId = new NodeId((uint)s_nextId++); + m_propertyNodeIds[qname] = nodeId; + m_values[nodeId] = value; + } + } +} diff --git a/Tests/Opc.Ua.Client.Tests/FileSystem/FileTypeSessionMock.cs b/Tests/Opc.Ua.Client.Tests/FileSystem/FileTypeSessionMock.cs new file mode 100644 index 0000000000..178561ac29 --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/FileSystem/FileTypeSessionMock.cs @@ -0,0 +1,178 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Opc.Ua.Tests; + +namespace Opc.Ua.Client.Tests.FileSystem +{ + /// + /// Lightweight scriptable mock around that + /// captures every CallMethodRequest and dispatches the call to a + /// per-method handler keyed by the method NodeId. + /// + internal sealed class FileTypeSessionMock + { + public FileTypeSessionMock() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + IServiceMessageContext messageContext = ServiceMessageContext.Create(telemetry); + + m_sessionMock = new Mock(MockBehavior.Strict); + m_sessionMock.SetupGet(s => s.MessageContext).Returns(messageContext); + m_sessionMock + .Setup(s => s.CallAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, requests, _) => + { + CallMethodRequest req = requests[0]; + Capture.Add(req); + Variant[] outputs = Dispatch(req) ?? []; + var result = new CallMethodResult + { + StatusCode = StatusCodes.Good, + OutputArguments = outputs.ToArrayOf() + }; + var response = new CallResponse + { + ResponseHeader = new ResponseHeader(), + Results = new[] { result }.ToArrayOf(), + DiagnosticInfos = default + }; + return new ValueTask(response); + }); + } + + public ISessionClient Session => m_sessionMock.Object; + + public List Capture { get; } = []; + + public List CapturedFor(uint methodId) + { + var matching = new List(); + foreach (CallMethodRequest req in Capture) + { + if (req.MethodId.IdType == IdType.Numeric && + req.MethodId.TryGetValue(out uint identifier) && + identifier == methodId) + { + matching.Add(req); + } + } + return matching; + } + + public void OnOpen(Func handler) + { + m_handlers[Methods.FileType_Open] = req => + { + req.InputArguments[0].TryGetValue(out byte mode); + uint handle = handler(mode); + return [new Variant(handle)]; + }; + } + + public void OnRead(Func handler) + { + m_handlers[Methods.FileType_Read] = req => + { + req.InputArguments[0].TryGetValue(out uint h); + req.InputArguments[1].TryGetValue(out int len); + byte[] payload = handler(h, len) ?? []; + return [new Variant(payload.ToByteString())]; + }; + } + + public void OnWrite(Action handler) + { + m_handlers[Methods.FileType_Write] = req => + { + req.InputArguments[0].TryGetValue(out uint h); + req.InputArguments[1].TryGetValue(out ByteString data); + handler(h, data.ToArray() ?? []); + return []; + }; + } + + public void OnSetPosition(Action handler) + { + m_handlers[Methods.FileType_SetPosition] = req => + { + req.InputArguments[0].TryGetValue(out uint h); + req.InputArguments[1].TryGetValue(out ulong pos); + handler(h, pos); + return []; + }; + } + + public void OnGetPosition(Func handler) + { + m_handlers[Methods.FileType_GetPosition] = req => + { + req.InputArguments[0].TryGetValue(out uint h); + ulong pos = handler(h); + return [new Variant(pos)]; + }; + } + + public void OnClose(Action handler) + { + m_handlers[Methods.FileType_Close] = req => + { + req.InputArguments[0].TryGetValue(out uint h); + handler(h); + return []; + }; + } + + private Variant[] Dispatch(CallMethodRequest req) + { + if (!req.MethodId.TryGetValue(out uint methodId)) + { + methodId = 0; + } + if (m_handlers.TryGetValue(methodId, out Func handler)) + { + return handler(req); + } + throw new InvalidOperationException( + $"FileTypeSessionMock: no handler registered for method id {methodId}."); + } + + private readonly Mock m_sessionMock; + private readonly Dictionary> m_handlers = []; + } +} diff --git a/Tests/Opc.Ua.Client.Tests/FileSystem/PathCacheTests.cs b/Tests/Opc.Ua.Client.Tests/FileSystem/PathCacheTests.cs new file mode 100644 index 0000000000..f014b86051 --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/FileSystem/PathCacheTests.cs @@ -0,0 +1,207 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using NUnit.Framework; +using Opc.Ua.Client.FileSystem; + +namespace Opc.Ua.Client.Tests.FileSystem +{ + /// + /// Unit tests for the internal PathCache LRU. The class is + /// internal but visible to this assembly via the + /// InternalsVisibleTo declaration in + /// Opc.Ua.Client.csproj. + /// + [TestFixture] + [Category("FileSystem")] + [Parallelizable] + public class PathCacheTests + { + [Test] + public void DisabledCacheReturnsNullAndDoesNotStore() + { + // ARRANGE + var cache = new PathCache(0); + var parent = new NodeId(1); + var name = new QualifiedName("foo"); + var child = new NodeId(2); + + // ACT + cache.Put(parent, name, child); + + // ASSERT + Assert.That(cache.TryGet(parent, name), Is.Null); + Assert.That(cache.Count, Is.Zero); + } + + [Test] + public void PutThenTryGetReturnsTheStoredValue() + { + var cache = new PathCache(8); + var parent = new NodeId(1); + var name = new QualifiedName("foo"); + var child = new NodeId(2); + + cache.Put(parent, name, child); + + NodeId? got = cache.TryGet(parent, name); + Assert.That(got, Is.Not.Null); + Assert.That(got!.Value, Is.EqualTo(child)); + Assert.That(cache.Count, Is.EqualTo(1)); + } + + [Test] + public void DifferentParentsAreIndependent() + { + var cache = new PathCache(8); + var nameA = new QualifiedName("foo"); + var nameB = new QualifiedName("foo"); + + cache.Put(new NodeId(1), nameA, new NodeId(10)); + cache.Put(new NodeId(2), nameB, new NodeId(20)); + + Assert.That(cache.TryGet(new NodeId(1), nameA)!.Value, Is.EqualTo(new NodeId(10))); + Assert.That(cache.TryGet(new NodeId(2), nameB)!.Value, Is.EqualTo(new NodeId(20))); + } + + [Test] + public void NamespacedSiblingsAreDistinct() + { + var cache = new PathCache(8); + var parent = new NodeId(1); + cache.Put(parent, new QualifiedName("foo", 1), new NodeId(10)); + cache.Put(parent, new QualifiedName("foo", 2), new NodeId(20)); + + Assert.That(cache.TryGet(parent, new QualifiedName("foo", 1))!.Value, Is.EqualTo(new NodeId(10))); + Assert.That(cache.TryGet(parent, new QualifiedName("foo", 2))!.Value, Is.EqualTo(new NodeId(20))); + } + + [Test] + public void PutOverwritesExistingEntry() + { + var cache = new PathCache(8); + var parent = new NodeId(1); + var name = new QualifiedName("foo"); + + cache.Put(parent, name, new NodeId(10)); + cache.Put(parent, name, new NodeId(99)); + + Assert.That(cache.TryGet(parent, name)!.Value, Is.EqualTo(new NodeId(99))); + Assert.That(cache.Count, Is.EqualTo(1)); + } + + [Test] + public void LruEvictsOldestWhenCapacityExceeded() + { + var cache = new PathCache(2); + var parent = new NodeId(1); + + cache.Put(parent, new QualifiedName("a"), new NodeId(10)); + cache.Put(parent, new QualifiedName("b"), new NodeId(20)); + cache.Put(parent, new QualifiedName("c"), new NodeId(30)); + + Assert.That(cache.Count, Is.EqualTo(2)); + Assert.That(cache.TryGet(parent, new QualifiedName("a")), Is.Null); + Assert.That(cache.TryGet(parent, new QualifiedName("b"))!.Value, Is.EqualTo(new NodeId(20))); + Assert.That(cache.TryGet(parent, new QualifiedName("c"))!.Value, Is.EqualTo(new NodeId(30))); + } + + [Test] + public void TryGetMovesEntryToFrontOfLru() + { + var cache = new PathCache(2); + var parent = new NodeId(1); + + cache.Put(parent, new QualifiedName("a"), new NodeId(10)); + cache.Put(parent, new QualifiedName("b"), new NodeId(20)); + // Touch 'a' so it becomes most recently used. + _ = cache.TryGet(parent, new QualifiedName("a")); + // Insert 'c' — 'b' should be evicted, not 'a'. + cache.Put(parent, new QualifiedName("c"), new NodeId(30)); + + Assert.That(cache.TryGet(parent, new QualifiedName("a"))!.Value, Is.EqualTo(new NodeId(10))); + Assert.That(cache.TryGet(parent, new QualifiedName("b")), Is.Null); + Assert.That(cache.TryGet(parent, new QualifiedName("c"))!.Value, Is.EqualTo(new NodeId(30))); + } + + [Test] + public void InvalidateRemovesNamedEntry() + { + var cache = new PathCache(8); + var parent = new NodeId(1); + cache.Put(parent, new QualifiedName("a"), new NodeId(10)); + cache.Put(parent, new QualifiedName("b"), new NodeId(20)); + + cache.Invalidate(parent, new QualifiedName("a")); + + Assert.That(cache.TryGet(parent, new QualifiedName("a")), Is.Null); + Assert.That(cache.TryGet(parent, new QualifiedName("b"))!.Value, Is.EqualTo(new NodeId(20))); + } + + [Test] + public void InvalidateChildrenOfRemovesAllEntriesForParent() + { + var cache = new PathCache(8); + var parentA = new NodeId(1); + var parentB = new NodeId(2); + cache.Put(parentA, new QualifiedName("x"), new NodeId(10)); + cache.Put(parentA, new QualifiedName("y"), new NodeId(11)); + cache.Put(parentB, new QualifiedName("z"), new NodeId(20)); + + cache.InvalidateChildrenOf(parentA); + + Assert.That(cache.TryGet(parentA, new QualifiedName("x")), Is.Null); + Assert.That(cache.TryGet(parentA, new QualifiedName("y")), Is.Null); + Assert.That(cache.TryGet(parentB, new QualifiedName("z"))!.Value, Is.EqualTo(new NodeId(20))); + Assert.That(cache.Count, Is.EqualTo(1)); + } + + [Test] + public void ClearRemovesEverything() + { + var cache = new PathCache(8); + var parent = new NodeId(1); + cache.Put(parent, new QualifiedName("a"), new NodeId(10)); + cache.Put(parent, new QualifiedName("b"), new NodeId(20)); + + cache.Clear(); + + Assert.That(cache.Count, Is.Zero); + Assert.That(cache.TryGet(parent, new QualifiedName("a")), Is.Null); + Assert.That(cache.TryGet(parent, new QualifiedName("b")), Is.Null); + } + + [Test] + public void NegativeCapacityThrows() + { + Assert.Throws(() => new PathCache(-1)); + } + } +} diff --git a/Tests/Opc.Ua.Client.Tests/FileSystem/TemporaryFileTransferClientTests.cs b/Tests/Opc.Ua.Client.Tests/FileSystem/TemporaryFileTransferClientTests.cs new file mode 100644 index 0000000000..3e9abd6fc2 --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/FileSystem/TemporaryFileTransferClientTests.cs @@ -0,0 +1,229 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.Client.FileSystem; +using Opc.Ua.Tests; + +namespace Opc.Ua.Client.Tests.FileSystem +{ + /// + /// Unit tests for that + /// validate the temp-write commit lifecycle (CloseAndCommit vs + /// Close, single terminal call). + /// + [TestFixture] + [Category("FileSystem")] + [Parallelizable] + public class TemporaryFileTransferClientTests + { + [Test] + public async Task CommitAsyncSendsCloseAndCommitOnceAsync() + { + using TempTransferHarness harness = TempTransferHarness.Create(); + UaTemporaryWriteFile temp = await harness + .GenerateForWriteAsync().ConfigureAwait(false); + await harness.WriteSomeBytesAsync(temp).ConfigureAwait(false); + + NodeId completion = await temp.CommitAsync().ConfigureAwait(false); + await temp.DisposeAsync().ConfigureAwait(false); + await temp.CommitAsync().ConfigureAwait(false); + + Assert.That(harness.CloseAndCommitCount, Is.EqualTo(1)); + Assert.That(harness.CloseCount, Is.Zero); + Assert.That(completion, Is.EqualTo(new NodeId(99))); + } + + [Test] + public async Task DisposeWithoutCommitSendsCloseAsync() + { + using TempTransferHarness harness = TempTransferHarness.Create(); + UaTemporaryWriteFile temp = await harness + .GenerateForWriteAsync().ConfigureAwait(false); + + await temp.DisposeAsync().ConfigureAwait(false); + await temp.DisposeAsync().ConfigureAwait(false); + + Assert.That(harness.CloseCount, Is.EqualTo(1)); + Assert.That(harness.CloseAndCommitCount, Is.Zero); + } + + [Test] + public async Task GenerateForReadReturnsStreamThatClosesHandleOnDisposeAsync() + { + using TempTransferHarness harness = TempTransferHarness.Create(); + UaFileStream stream = await harness.Client + .GenerateFileForReadAsync(default, CancellationToken.None) + .ConfigureAwait(false); + + await stream.DisposeAsync().ConfigureAwait(false); + + Assert.That(harness.CloseCount, Is.EqualTo(1)); + } + + [Test] + public async Task TempStreamWrapperDisposeDoesNotCloseHandleAsync() + { + using TempTransferHarness harness = TempTransferHarness.Create(); + UaTemporaryWriteFile temp = await harness + .GenerateForWriteAsync().ConfigureAwait(false); + + // Disposing the wrapped Stream must NOT close the server handle. + temp.Stream.Dispose(); + Assert.That(harness.CloseCount, Is.Zero); + Assert.That(harness.CloseAndCommitCount, Is.Zero); + + await temp.CommitAsync().ConfigureAwait(false); + Assert.That(harness.CloseAndCommitCount, Is.EqualTo(1)); + } + + private sealed class TempTransferHarness : IDisposable + { + private readonly Dictionary> m_handlers = []; + + private TempTransferHarness(TemporaryFileTransferClient client) + { + Client = client; + } + + public TemporaryFileTransferClient Client { get; } + + public int CloseAndCommitCount { get; private set; } + public int CloseCount { get; private set; } + + public static TempTransferHarness Create() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + IServiceMessageContext messageContext = ServiceMessageContext.Create(telemetry); + var sessionMock = new Mock(MockBehavior.Loose); + sessionMock.SetupGet(s => s.MessageContext).Returns(messageContext); + + TempTransferHarness harness = null; + sessionMock + .Setup(s => s.CallAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, requests, _) => + { + CallMethodRequest req = requests[0]; + req.MethodId.TryGetValue(out uint methodId); + Variant[] outputs = harness!.m_handlers[methodId](req); + var result = new CallMethodResult + { + StatusCode = StatusCodes.Good, + OutputArguments = outputs.ToArrayOf() + }; + var response = new CallResponse + { + ResponseHeader = new ResponseHeader(), + Results = new[] { result }.ToArrayOf(), + DiagnosticInfos = default + }; + return new ValueTask(response); + }); + + var client = new TemporaryFileTransferClient( + sessionMock.Object, + new NodeId(1000)); + + harness = new TempTransferHarness(client); + harness.RegisterHandlers(); + return harness; + } + + public async Task GenerateForWriteAsync() + { + return await Client + .GenerateFileForWriteAsync(default, CancellationToken.None) + .ConfigureAwait(false); + } + + public async Task WriteSomeBytesAsync(UaTemporaryWriteFile temp) + { + byte[] payload = [1, 2, 3]; +#if NETSTANDARD2_1_OR_GREATER || NET + await temp.Stream + .WriteAsync(payload.AsMemory(), CancellationToken.None) + .ConfigureAwait(false); +#else + await temp.Stream + .WriteAsync(payload, 0, payload.Length, CancellationToken.None) + .ConfigureAwait(false); +#endif + } + + public void Dispose() + { + // No-op (mock-only). + } + + private void RegisterHandlers() + { + m_handlers[Methods.FileType_Close] = _ => + { + CloseCount++; + return []; + }; + m_handlers[Methods.FileType_Write] = _ => []; + m_handlers[Methods.FileType_SetPosition] = _ => []; + m_handlers[Methods.FileType_Read] = _ => + [new Variant(Array.Empty().ToByteString())]; + + // GenerateFileForRead → (NodeId, uint handle, NodeId completionStateMachine). + m_handlers[Methods.TemporaryFileTransferType_GenerateFileForRead] = _ => + [ + new Variant(new NodeId(123)), + new Variant(7u), + new Variant(NodeId.Null) + ]; + + // GenerateFileForWrite → (NodeId, uint handle). + m_handlers[Methods.TemporaryFileTransferType_GenerateFileForWrite] = _ => + [ + new Variant(new NodeId(123)), + new Variant(7u) + ]; + + // CloseAndCommit → NodeId. + m_handlers[Methods.TemporaryFileTransferType_CloseAndCommit] = _ => + { + CloseAndCommitCount++; + return [new Variant(new NodeId(99))]; + }; + } + } + } +} diff --git a/Tests/Opc.Ua.Client.Tests/FileSystem/UaFileStreamTests.cs b/Tests/Opc.Ua.Client.Tests/FileSystem/UaFileStreamTests.cs new file mode 100644 index 0000000000..d87db526ef --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/FileSystem/UaFileStreamTests.cs @@ -0,0 +1,401 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +// CA1835: The byte[]-based ReadAsync/WriteAsync overload is used +// throughout because the test fixture targets all TFMs of the parent +// project (incl. net472/net48 which do not expose the Memory +// overrides). The behaviour is identical on net10+. +#pragma warning disable CA1835 + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Client.FileSystem; + +namespace Opc.Ua.Client.Tests.FileSystem +{ + /// + /// Unit tests for . Uses a mock + /// via ; + /// the underlying FileTypeClient proxy translates the + /// async wrappers into Call requests. + /// + /// + /// Tests use the + /// await using (stream.ConfigureAwait(false)) block pattern + /// so the dispose itself respects ConfigureAwait, matching the + /// convention used elsewhere in Opc.Ua.Client.Tests. + /// + [TestFixture] + [Category("FileSystem")] + [Parallelizable] + public class UaFileStreamTests + { + private FileTypeSessionMock m_session; + private FileTypeClient m_proxy; + + [SetUp] + public void Setup() + { + m_session = new FileTypeSessionMock(); + m_proxy = new FileTypeClient( + m_session.Session, + new NodeId(42), + m_session.Session.MessageContext.Telemetry); + m_session.OnClose(_ => { }); + } + + private UaFileStream NewStream( + UaFileMode mode = UaFileMode.Read, + long length = 1024, + long position = 0, + int chunkSize = 4) + { + return new UaFileStream( + m_proxy, + handle: 7, + mode, + initialLength: length, + initialPosition: position, + chunkSize); + } + + [Test] + public async Task ReadAsyncChunksAcrossMultipleServerCallsAsync() + { + byte[] payload = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + int offset = 0; + m_session.OnRead((handle, len) => + { + int remaining = payload.Length - offset; + int take = Math.Min(remaining, len); + byte[] slice = payload.AsSpan(offset, take).ToArray(); + offset += take; + return slice; + }); + + UaFileStream stream = NewStream( + UaFileMode.Read, length: payload.Length, chunkSize: 4); + await using (stream.ConfigureAwait(false)) + { + byte[] buffer = new byte[10]; + int total = await stream + .ReadAsync(buffer, 0, 10, CancellationToken.None) + .ConfigureAwait(false); + Assert.That(total, Is.EqualTo(10)); + Assert.That(buffer, Is.EqualTo(payload)); + } + Assert.That( + m_session.CapturedFor(Methods.FileType_Read), + Has.Count.EqualTo(3)); + } + + [Test] + public async Task ReadAsyncEmptyByteStringMeansEofAsync() + { + int callCount = 0; + m_session.OnRead((_, _) => + { + callCount++; + return []; + }); + + UaFileStream stream = NewStream(UaFileMode.Read); + await using (stream.ConfigureAwait(false)) + { + byte[] buffer = new byte[8]; + int read = await stream + .ReadAsync(buffer, 0, 8, CancellationToken.None) + .ConfigureAwait(false); + Assert.That(read, Is.Zero); + Assert.That(callCount, Is.EqualTo(1)); + } + } + + [Test] + public async Task ZeroLengthReadDoesNotHitTheWireAsync() + { + UaFileStream stream = NewStream(UaFileMode.Read); + await using (stream.ConfigureAwait(false)) + { + int read = await stream + .ReadAsync([], 0, 0, CancellationToken.None) + .ConfigureAwait(false); + Assert.That(read, Is.Zero); + Assert.That(m_session.CapturedFor(Methods.FileType_Read), Is.Empty); + } + } + + [Test] + public async Task WriteAsyncChunksAtChunkSizeBoundaryAsync() + { + var written = new List(); + m_session.OnWrite((_, data) => written.AddRange(data)); + + UaFileStream stream = NewStream( + UaFileMode.Write, length: 0, chunkSize: 3); + await using (stream.ConfigureAwait(false)) + { + byte[] payload = [1, 2, 3, 4, 5, 6, 7]; + await stream + .WriteAsync(payload, 0, 7, CancellationToken.None) + .ConfigureAwait(false); + } + + Assert.That(written, Is.EqualTo(new byte[] { 1, 2, 3, 4, 5, 6, 7 })); + Assert.That( + m_session.CapturedFor(Methods.FileType_Write), + Has.Count.EqualTo(3)); + } + + [Test] + public async Task ZeroLengthWriteDoesNotHitTheWireAsync() + { + m_session.OnWrite((_, _) => { }); + UaFileStream stream = NewStream(UaFileMode.Write); + await using (stream.ConfigureAwait(false)) + { + await stream + .WriteAsync([], 0, 0, CancellationToken.None) + .ConfigureAwait(false); + Assert.That(m_session.CapturedFor(Methods.FileType_Write), Is.Empty); + } + } + + [Test] + public async Task WriteExtendsLengthAsync() + { + m_session.OnWrite((_, _) => { }); + UaFileStream stream = NewStream( + UaFileMode.Write, length: 0, chunkSize: 8); + await using (stream.ConfigureAwait(false)) + { + await stream + .WriteAsync(new byte[5], 0, 5, CancellationToken.None) + .ConfigureAwait(false); + Assert.That(stream.Length, Is.EqualTo(5)); + Assert.That(stream.Position, Is.EqualTo(5)); + } + } + + [Test] + public async Task SeekDoesNotCallSetPositionUntilNextOperationAsync() + { + m_session.OnSetPosition((_, _) => { }); + m_session.OnRead((_, _) => []); + + UaFileStream stream = NewStream(UaFileMode.Read, length: 100); + await using (stream.ConfigureAwait(false)) + { + stream.Seek(20, SeekOrigin.Begin); + Assert.That(stream.Position, Is.EqualTo(20)); + Assert.That(m_session.CapturedFor(Methods.FileType_SetPosition), Is.Empty); + + byte[] tmp = new byte[1]; + _ = await stream + .ReadAsync(tmp, 0, 1, CancellationToken.None) + .ConfigureAwait(false); + } + + List setPositions = + m_session.CapturedFor(Methods.FileType_SetPosition); + Assert.That(setPositions, Has.Count.EqualTo(1)); + setPositions[0].InputArguments[1].TryGetValue(out ulong pushed); + Assert.That(pushed, Is.EqualTo(20UL)); + } + + [Test] + public async Task SecondReadAfterFirstDoesNotResendSetPositionAsync() + { + m_session.OnSetPosition((_, _) => { }); + int callCount = 0; + m_session.OnRead((_, len) => + { + callCount++; + return new byte[Math.Min(len, 4)]; + }); + + UaFileStream stream = NewStream( + UaFileMode.Read, length: 100, chunkSize: 4); + await using (stream.ConfigureAwait(false)) + { + byte[] buffer = new byte[8]; + _ = await stream + .ReadAsync(buffer, 0, 8, CancellationToken.None) + .ConfigureAwait(false); + } + + Assert.That(m_session.CapturedFor(Methods.FileType_SetPosition), Is.Empty); + Assert.That(callCount, Is.EqualTo(2)); + } + + [Test] + public async Task SeekBeforeStartThrowsAsync() + { + UaFileStream stream = NewStream(UaFileMode.Read, length: 100); + await using (stream.ConfigureAwait(false)) + { + Assert.Throws(() => stream.Seek(-1, SeekOrigin.Begin)); + } + } + + [Test] + public async Task SetLengthThrowsNotSupportedAsync() + { + UaFileStream stream = NewStream(UaFileMode.Write); + await using (stream.ConfigureAwait(false)) + { + Assert.Throws(() => stream.SetLength(0)); + } + } + + [Test] + public async Task DisposeAsyncCallsCloseExactlyOnceAsync() + { + int closeCount = 0; + m_session.OnClose(_ => closeCount++); + UaFileStream stream = NewStream(UaFileMode.Read); + await stream.DisposeAsync().ConfigureAwait(false); + await stream.DisposeAsync().ConfigureAwait(false); + Assert.That(closeCount, Is.EqualTo(1)); + } + + [Test] + public async Task ReadAfterDisposeThrowsAsync() + { + UaFileStream stream = NewStream(UaFileMode.Read); + await stream.DisposeAsync().ConfigureAwait(false); + byte[] buffer = new byte[1]; + Assert.ThrowsAsync( + async () => await stream + .ReadAsync(buffer, 0, 1, CancellationToken.None) + .ConfigureAwait(false)); + } + + [Test] + public async Task ReadOnReadOnlyStreamThrowsOnWriteAsync() + { + UaFileStream stream = NewStream(UaFileMode.Read); + await using (stream.ConfigureAwait(false)) + { + Assert.ThrowsAsync( + async () => await stream + .WriteAsync(new byte[1], 0, 1, CancellationToken.None) + .ConfigureAwait(false)); + } + } + + [Test] + public async Task WriteOnWriteOnlyStreamThrowsOnReadAsync() + { + UaFileStream stream = NewStream(UaFileMode.Write); + await using (stream.ConfigureAwait(false)) + { + Assert.ThrowsAsync( + async () => await stream + .ReadAsync(new byte[1], 0, 1, CancellationToken.None) + .ConfigureAwait(false)); + } + } + + [Test] + public async Task SyncReadProducesSameResultAsAsyncReadAsync() + { + byte[] payload = [10, 20, 30, 40, 50]; + int offset = 0; + m_session.OnRead((_, len) => + { + int remaining = payload.Length - offset; + int take = Math.Min(remaining, len); + byte[] slice = payload.AsSpan(offset, take).ToArray(); + offset += take; + return slice; + }); + + UaFileStream stream = NewStream( + UaFileMode.Read, length: payload.Length, chunkSize: 8); + await using (stream.ConfigureAwait(false)) + { + byte[] buffer = new byte[5]; + int read = stream.Read(buffer, 0, 5); + Assert.That(read, Is.EqualTo(5)); + Assert.That(buffer, Is.EqualTo(payload)); + } + } + + [Test] + public async Task ReadCallsTargetCorrectMethodIdAsync() + { + m_session.OnRead((_, _) => new byte[] { 1, 2 }); + UaFileStream stream = NewStream( + UaFileMode.Read, length: 2, chunkSize: 2); + await using (stream.ConfigureAwait(false)) + { + _ = await stream + .ReadAsync(new byte[2], 0, 2, CancellationToken.None) + .ConfigureAwait(false); + } + + CallMethodRequest req = m_session.Capture + .First(r => r.MethodId.TryGetValue(out uint id) && + id == Methods.FileType_Read); + req.InputArguments[0].TryGetValue(out uint handle); + req.InputArguments[1].TryGetValue(out int length); + Assert.That(handle, Is.EqualTo(7u)); + Assert.That(length, Is.EqualTo(2)); + Assert.That(req.ObjectId, Is.EqualTo(new NodeId(42))); + } + + [Test] + public async Task WriteCallsTargetCorrectMethodIdAsync() + { + m_session.OnWrite((_, _) => { }); + UaFileStream stream = NewStream( + UaFileMode.Write, length: 0, chunkSize: 8); + await using (stream.ConfigureAwait(false)) + { + await stream + .WriteAsync(new byte[] { 9, 8, 7 }, 0, 3, CancellationToken.None) + .ConfigureAwait(false); + } + + CallMethodRequest req = m_session.Capture + .First(r => r.MethodId.TryGetValue(out uint id) && + id == Methods.FileType_Write); + req.InputArguments[0].TryGetValue(out uint handle); + req.InputArguments[1].TryGetValue(out ByteString data); + Assert.That(handle, Is.EqualTo(7u)); + Assert.That(data.ToArray(), Is.EqualTo(new byte[] { 9, 8, 7 })); + } + } +} diff --git a/Tests/Opc.Ua.Client.Tests/FileSystem/UaPathTests.cs b/Tests/Opc.Ua.Client.Tests/FileSystem/UaPathTests.cs new file mode 100644 index 0000000000..d14125866a --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/FileSystem/UaPathTests.cs @@ -0,0 +1,259 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using NUnit.Framework; +using Opc.Ua.Client.FileSystem; + +namespace Opc.Ua.Client.Tests.FileSystem +{ + /// + /// Unit tests for . + /// + [TestFixture] + [Category("FileSystem")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [Parallelizable] + public class UaPathTests + { + [Test] + public void ParseEmptyReturnsEmptyArray() + { + Assert.That(UaPath.Parse(string.Empty), Is.Empty); + } + + [Test] + public void ParseRootReturnsEmptyArray() + { + Assert.That(UaPath.Parse("/"), Is.Empty); + } + + [Test] + public void ParseSingleSegmentReturnsOneEntry() + { + QualifiedName[] segments = UaPath.Parse("foo"); + Assert.That(segments, Has.Length.EqualTo(1)); + Assert.That(segments[0].Name, Is.EqualTo("foo")); + Assert.That(segments[0].NamespaceIndex, Is.Zero); + } + + [Test] + public void ParseLeadingAndTrailingSlashesAreTolerated() + { + QualifiedName[] segments = UaPath.Parse("/foo/bar/"); + Assert.That(segments, Has.Length.EqualTo(2)); + Assert.That(segments[0].Name, Is.EqualTo("foo")); + Assert.That(segments[1].Name, Is.EqualTo("bar")); + } + + [Test] + public void ParseQualifiedSegmentRetainsNamespaceIndex() + { + QualifiedName[] segments = UaPath.Parse("1:Reports/2:2024/data.csv"); + Assert.That(segments, Has.Length.EqualTo(3)); + Assert.That(segments[0].NamespaceIndex, Is.EqualTo(1)); + Assert.That(segments[0].Name, Is.EqualTo("Reports")); + Assert.That(segments[1].NamespaceIndex, Is.EqualTo(2)); + Assert.That(segments[1].Name, Is.EqualTo("2024")); + Assert.That(segments[2].NamespaceIndex, Is.Zero); + Assert.That(segments[2].Name, Is.EqualTo("data.csv")); + } + + [Test] + public void ParseEmptyMiddleSegmentThrows() + { + ArgumentException ex = Assert.Throws( + () => UaPath.Parse("foo//bar")); + Assert.That(ex.Message, Does.Contain("empty segment")); + } + + [Test] + public void ParseInvalidNamespacePrefixThrows() + { + ArgumentException ex = Assert.Throws( + () => UaPath.Parse("abc:foo")); + Assert.That(ex.Message, Does.Contain("non-numeric")); + } + + [Test] + public void ParseEmptyNamespacePrefixThrows() + { + Assert.Throws(() => UaPath.Parse(":foo")); + } + + [Test] + public void ParseEmptyNameAfterNamespacePrefixThrows() + { + Assert.Throws(() => UaPath.Parse("1:")); + } + + [Test] + public void ParseNullThrowsArgumentNullException() + { + Assert.Throws(() => UaPath.Parse(null!)); + } + + [Test] + public void FormatEmptyReturnsRoot() + { + Assert.That(UaPath.Format([]), Is.EqualTo("/")); + } + + [Test] + public void FormatPreservesNamespaceIndex() + { + string formatted = UaPath.Format( + [ + new QualifiedName("Reports", 1), + new QualifiedName("2024", 2), + new QualifiedName("data.csv") + ]); + Assert.That(formatted, Is.EqualTo("/1:Reports/2:2024/data.csv")); + } + + [Test] + public void FormatRoundTripsThroughParse() + { + const string original = "/1:Reports/2:2024/data.csv"; + string roundTripped = UaPath.Format(UaPath.Parse(original)); + Assert.That(roundTripped, Is.EqualTo(original)); + } + + [Test] + public void FormatSegmentNs0OmitsPrefix() + { + Assert.That( + UaPath.FormatSegment(new QualifiedName("foo")), + Is.EqualTo("foo")); + } + + [Test] + public void FormatSegmentNs1IncludesPrefix() + { + Assert.That( + UaPath.FormatSegment(new QualifiedName("foo", 1)), + Is.EqualTo("1:foo")); + } + + [Test] + public void FormatSegmentEmptyNameThrows() + { + Assert.Throws( + () => UaPath.FormatSegment(QualifiedName.Null)); + } + + [Test] + public void CombineWithRelativeRightAppends() + { + Assert.That( + UaPath.Combine("/foo", "bar"), + Is.EqualTo("/foo/bar")); + } + + [Test] + public void CombineWithAbsoluteRightReplacesLeft() + { + Assert.That( + UaPath.Combine("/foo", "/baz/qux"), + Is.EqualTo("/baz/qux")); + } + + [Test] + public void CombineNullLeftIsRoot() + { + Assert.That( + UaPath.Combine(null!, "bar"), + Is.EqualTo("/bar")); + } + + [Test] + public void CombineEmptyRightReturnsLeft() + { + Assert.That( + UaPath.Combine("/foo", string.Empty), + Is.EqualTo("/foo")); + } + + [Test] + public void CombineNullRightThrows() + { + Assert.Throws( + () => UaPath.Combine("/foo", null!)); + } + + [Test] + public void GetDirectoryNameOfRootReturnsNull() + { + Assert.That(UaPath.GetDirectoryName("/"), Is.Null); + } + + [Test] + public void GetDirectoryNameOfSingleSegmentReturnsRoot() + { + Assert.That(UaPath.GetDirectoryName("foo"), Is.EqualTo("/")); + } + + [Test] + public void GetDirectoryNameOfMultipleSegmentsReturnsParent() + { + Assert.That( + UaPath.GetDirectoryName("/1:Reports/2024/data.csv"), + Is.EqualTo("/1:Reports/2024")); + } + + [Test] + public void GetFileNameOfRootReturnsNullQualifiedName() + { + Assert.That(UaPath.GetFileName("/").IsNull, Is.True); + } + + [Test] + public void GetFileNameReturnsLeafSegment() + { + QualifiedName name = UaPath.GetFileName("/1:Reports/2024/data.csv"); + Assert.That(name.NamespaceIndex, Is.Zero); + Assert.That(name.Name, Is.EqualTo("data.csv")); + } + + [Test] + public void NormalizeAddsLeadingSlashAndStripsTrailing() + { + Assert.That(UaPath.Normalize("foo/bar/"), Is.EqualTo("/foo/bar")); + } + + [Test] + public void NamespacedSiblingsProduceDistinctCanonicalPaths() + { + string a = UaPath.Format([new QualifiedName("foo", 1)]); + string b = UaPath.Format([new QualifiedName("foo", 2)]); + Assert.That(a, Is.Not.EqualTo(b)); + } + } +} diff --git a/Tools/Opc.Ua.SourceGeneration/readme.md b/Tools/Opc.Ua.SourceGeneration/readme.md index 01733c4a20..7e24184ad0 100644 --- a/Tools/Opc.Ua.SourceGeneration/readme.md +++ b/Tools/Opc.Ua.SourceGeneration/readme.md @@ -110,6 +110,11 @@ core NodeSet (e.g. `FileTypeClient`, `TrustListTypeClient`, `ServerConfigurationTypeClient`, …) emitted into the `Opc.Ua` namespace, so downstream models can simply derive from them. +For an ergonomic, `System.IO`-style async client built on top of the +generated `FileTypeClient` / `FileDirectoryTypeClient` / +`TemporaryFileTransferTypeClient` proxies, see +[`Docs/FileSystemClient.md`](../../Docs/FileSystemClient.md). + ### Output namespace By default each model emits its proxies into its **own** namespace — From 39f5f2ad4b747e0483f48edfd0df3c82ff096a83 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 10:47:29 +0200 Subject: [PATCH 2/5] Add .github/agents/dotnet-format.agent.md New repo agent that scopes 'dotnet format' (whitespace + style + analyzers --severity info) to user-specified files, then chases remaining CA/IDE/RCS warnings to a 0-warning build. Includes a per-warning-code fix cookbook (CA1835, CA2007, CA2213, CA2215, CA1844, CA1861, CA1068, CA1859, CA1307/CA2249, RCS1007, RCS1135, RCS1166), the recommended DisposeAsync pattern from the .NET docs, and a list of anti-patterns observed when applying the same workflow manually. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/agents/dotnet-format.agent.md | 164 ++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 .github/agents/dotnet-format.agent.md diff --git a/.github/agents/dotnet-format.agent.md b/.github/agents/dotnet-format.agent.md new file mode 100644 index 0000000000..e3cefa6d68 --- /dev/null +++ b/.github/agents/dotnet-format.agent.md @@ -0,0 +1,164 @@ +--- +description: "Use this agent when the user asks to run `dotnet format`, normalise whitespace, apply style or analyzer fixes, or fix code-analysis warnings on changed files.\n\nTrigger phrases include:\n- 'run dotnet format'\n- 'format the new code'\n- 'apply dotnet format on changed files'\n- 'fix CA warnings on the new code'\n- 'fix style and whitespace'\n- 'normalize whitespace'\n- 'clean up the diagnostics on these files'\n\nExamples:\n- User says 'Run dotnet format style and whitespace on the new code' → invoke this agent.\n- User says 'Fix all warnings and diagnostics on the new tests' → invoke this agent.\n- After completing a feature, user says 'Clean up the format on the files I changed' → invoke this agent to run the three-phase format sweep." +name: dotnet-format +--- + +# dotnet-format agent instructions + +You are a `dotnet format` automation agent for the OPC UA .NET Standard repository. Your job is to apply `dotnet format` (whitespace, style, and analyzer fixes) to a focused, well-scoped set of files — typically *only the files the user has just added or modified* — and to chase every remaining warning and info-level diagnostic until the affected projects build with **0 errors and 0 warnings**. + +## Repository layout + +Production code lives under `Libraries/`, `Stack/`, and `Applications/`; tests live under `Tests/`. Most projects target multiple TFMs (`net472;net48;netstandard2.1;net8.0;net9.0;net10.0`). The repo has `TreatWarningsAsErrors=true` (`common.props:16`) but `CodeAnalysisTreatWarningsAsErrors=false` — so CA warnings build but do not fail the build. Your job is to fix them anyway. + +Existing agents in `.github/agents/` show the format and tone used in this repo. Follow that style. + +## Workflow + +### 1. Identify scope + +Determine *which projects own the files in scope* and *which files are in scope*: + +* If the user names files / a folder, use that. +* If the user says "the new code" or "the changes I just made", run `git status --short` and pick the new/modified `.cs` files; group them by owning `.csproj`. +* Never reformat files outside the user's intent — the repo has 150+ pre-existing warnings the user is not asking you to touch. + +Use `dotnet format --include ` (path can be a folder or comma-separated list of files) to constrain the run. + +### 2. Run the three-phase format sweep + +`dotnet format` has three sub-commands. Run them in this order, on each owning project, scoped with `--include`: + +```powershell +# 1. Whitespace (cheapest; tabs → spaces, trailing whitespace, newline-at-EOF, brace placement) +dotnet format whitespace --include --no-restore --verbosity minimal + +# 2. Style (.editorconfig style rules: var vs explicit, qualification, modifier order, …) +dotnet format style --include --no-restore --verbosity minimal + +# 3. Analyzers (Roslyn analyzers — CA/IDE/RCS at the requested severity) +dotnet format analyzers --include --no-restore --severity info --verbosity minimal +``` + +Notes: +* The `--severity info` flag on the analyzers phase is intentional — it picks up everything `dotnet build` does plus the info-level diagnostics that the build does not surface (e.g. `RCS1135` "Declare enum member with zero value (when enum has FlagsAttribute)"). +* Run each phase to completion before starting the next; some style fixes resolve later analyzer warnings, and analyzer fixes can introduce new style issues. +* Pre-existing source-generation log lines in the output are noise — focus on the `Formatted code file` / `info` / `warning` lines. + +### 3. Verify + +After the sweep, run `--verify-no-changes` for each of the three phases on each project. Exit code `0` means nothing to do; anything else means leftover work: + +```powershell +dotnet format whitespace --include --no-restore --verify-no-changes --verbosity minimal +dotnet format style --include --no-restore --verify-no-changes --verbosity minimal +dotnet format analyzers --include --no-restore --severity info --verify-no-changes --verbosity minimal +``` + +Then build the project(s) and the dependent test project(s): + +```powershell +dotnet build -c Debug --nologo -v minimal +``` + +The build must report **`0 Warning(s)` and `0 Error(s)`**. If warnings remain, treat them as bugs to fix — see step 4. + +### 4. Fix what the analyzers cannot auto-fix + +`dotnet format analyzers` only applies fixes that have a Roslyn code-fix provider. Many CA warnings (CA1835, CA2007, CA2213, CA1844, RCS1135, …) need manual edits. Walk the build output and address each warning: + +| Warning | Typical fix | +|---|---| +| **CA1835** (use `Memory` overload of `Stream.ReadAsync`) | Switch to the `Memory` / `ReadOnlyMemory` overload; on `net472`/`net48` keep the byte[] overload behind an `#if NETSTANDARD2_1_OR_GREATER \|\| NET` block since those frameworks don't expose it. | +| **CA2007** on a plain `await something` | Add `.ConfigureAwait(false)`. | +| **CA2007** on an `await using` declaration | Convert to the block-scope form recommended at :
`var x = await GetItAsync().ConfigureAwait(false);`
`await using (x.ConfigureAwait(false)) { … }` | +| **CA2213** (disposable field not disposed) | If ownership is intentional (e.g. the field's lifecycle is owned by another component), wrap the field in a `#pragma warning disable CA2213` / `#pragma warning restore CA2213` block with a one-line comment explaining why. Otherwise dispose the field in `Dispose(bool)`. | +| **CA2215** (overriding `DisposeAsync` should call `base.DisposeAsync`) | Override per the MS docs pattern: `await DisposeAsyncCore().ConfigureAwait(false); await base.DisposeAsync().ConfigureAwait(false); GC.SuppressFinalize(this);` | +| **CA1844** (override the `Memory` `ReadAsync` / `WriteAsync` too) | Add the matching `Memory`-based override (gated by `#if NETSTANDARD2_1_OR_GREATER \|\| NET`). | +| **CA1861** (prefer `static readonly` array fields over inline literal arrays) | For one-shot test assertions where the array IS the expected literal value, suppress at file level with a comment. Otherwise lift the array to a `static readonly` field. | +| **CA1068** (`CancellationToken` should be the last parameter) | Move `CancellationToken` to be the last parameter on the method and at the call sites. | +| **CA1859** (return the concrete type for performance) | Change the return type from the interface (`IReadOnlyList`) to the concrete (`T[]`). | +| **CA1307** / **CA2249** (use `StringComparison` / `Contains`) | Switch `s.IndexOf(c) >= 0` to `s.Contains(c, StringComparison.Ordinal)`. | +| **RCS1007** (add braces to single-line `if`) | Add braces; per repo style every `if` body uses braces. | +| **RCS1135** (Flags enum needs a zero value) | Add `None = 0`. | +| **RCS1166** ("Value type object is never equal to null") | Replace `if (s is null \|\| s.IsNull)` with just `if (s.IsNull)` for value types. | + +### 5. Dispose patterns from MS docs + +When you add or refactor a disposable type, use the **`DisposeAsync` pattern** documented at : + +```csharp +public class ExampleAsyncDisposable : IAsyncDisposable, IDisposable +{ + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore().ConfigureAwait(false); + Dispose(disposing: false); + GC.SuppressFinalize(this); + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + protected virtual async ValueTask DisposeAsyncCore() { /* async cleanup */ } + protected virtual void Dispose(bool disposing) { /* sync cleanup if disposing */ } +} +``` + +For **sealed** classes the `protected virtual` members become `private`. For types inheriting from `Stream`, override `DisposeAsync` and `Dispose(bool)` instead of declaring fresh ones, and call `base.DisposeAsync()` / `base.Dispose(disposing)` at the tail. + +When *consuming* an `IAsyncDisposable` with `await using`, prefer the explicit `ConfigureAwait(false)` form so CA2007 stays satisfied: + +```csharp +SomeStream stream = await OpenAsync(ct).ConfigureAwait(false); +await using (stream.ConfigureAwait(false)) +{ + // … +} +``` + +`await using var x = await GetItAsync().ConfigureAwait(false);` (the declaration-form short-hand) cannot apply `ConfigureAwait` to the implicit dispose-await — use the block-scope form instead. + +### 6. Re-run tests + +Anything touching `await using` / `Dispose` / `Memory` / method ordering can subtly change runtime behaviour. After the format + warning sweep, run the test suite that covers the changed files: + +```powershell +dotnet test -c Debug -f net10.0 --filter "FullyQualifiedName~" --nologo --no-build -v quiet +``` + +A green run is the final acceptance bar. If tests regress, the change that caused it must be reverted or fixed. + +## Suppressions — when and how + +Prefer fixing the underlying issue. Suppress only when: + +1. The warning is a style preference inappropriate for the call site (e.g. `CA1861` on a one-shot literal expected-value in a unit test). +2. The fix would be more complex than the suppression (e.g. `CA2213` on a field whose lifecycle is intentionally owned by another component). + +When suppressing: +* Use a file-level `#pragma warning disable XXNNNN` with a one-line comment explaining the reason. +* Keep the scope as narrow as possible (file > member > line). Prefer `#pragma warning disable XXNNNN` + `#pragma warning restore XXNNNN` around the smallest block. +* Never add a blanket `#pragma warning disable` to "make things compile". + +## Anti-patterns to avoid + +* Do **not** run `dotnet format` over the whole solution / project without `--include` — this will rewrite hundreds of unrelated files. +* Do **not** ignore CA1835/CA1844 on the basis that "the test still passes" — the byte[] vs Memory overload mismatch on `Stream` is a real perf hazard. +* Do **not** use `await using var x = await GetItAsync().ConfigureAwait(false);` and assume CA2007 is satisfied — it is not, because the implicit `DisposeAsync` await is unconfigured. Use the block-scope form. +* Do **not** sync-call into `DisposeAsync` via `.GetAwaiter().GetResult()` from `Dispose(bool)` when a proper `Dispose(bool)` path exists — it deadlocks under a single-threaded sync context. +* Do **not** suppress warnings via `` in the `.csproj`; the project-wide convention is per-file pragmas with a justification comment. + +## Output format + +End with a short report: +* Phases run (whitespace / style / analyzers) and on which projects. +* Files changed (count + brief categorisation). +* Diagnostics fixed (table of warning codes addressed). +* Final build result (`0 Warning(s)` confirmation). +* Test result (passing count, any regressions). + +The user should be able to read the report and immediately understand what changed and that the result is clean. From 01fce1687f1ead4f62b51c5d6b282e7f45e3f360 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 11:48:34 +0200 Subject: [PATCH 3/5] Fix flaky GetIssuersAsyncReturnedReferencesAreCallerOwnedAndDisposable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test asserts disposedDelta == createdDelta on the process-wide Certificate.InstancesCreated / InstancesDisposed counters, but the owning fixture is [Parallelizable]. Any other test in the Opc.Ua.Core.Tests assembly (6729 tests) that allocates a Certificate during the snapshot window inflates createdDelta without a matching disposedDelta, intermittently failing the assertion on the Windows CI runner (the race is platform-sensitive — Ubuntu hits a different schedule and usually misses it). Marking the single test [NonParallelizable] gives it exclusive access to the counters for its ~267 ms run. The rest of the fixture stays [Parallelizable], so the wall-clock impact is negligible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CertificateManager/CertificateManagerTests.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Tests/Opc.Ua.Core.Tests/Security/Certificates/CertificateManager/CertificateManagerTests.cs b/Tests/Opc.Ua.Core.Tests/Security/Certificates/CertificateManager/CertificateManagerTests.cs index 1954ac4ca1..c742283201 100644 --- a/Tests/Opc.Ua.Core.Tests/Security/Certificates/CertificateManager/CertificateManagerTests.cs +++ b/Tests/Opc.Ua.Core.Tests/Security/Certificates/CertificateManager/CertificateManagerTests.cs @@ -490,6 +490,14 @@ await rootForStore.AddToStoreAsync( /// the issuer references are released. /// [Test] + [NonParallelizable] + // Uses process-wide Certificate.InstancesCreated / + // InstancesDisposed counters to verify caller-owned disposal. + // The fixture is [Parallelizable], so without this attribute + // any other test in the assembly that allocates a Certificate + // during the snapshot window inflates createdDelta without a + // matching disposedDelta. [NonParallelizable] grants the test + // exclusive access to the counters for its ~267 ms run. public async Task GetIssuersAsyncReturnedReferencesAreCallerOwnedAndDisposable() { using Certificate rootCa = CertificateBuilder From dc8b0cd51f8f31320b0653a7b351c42c034648b5 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Wed, 13 May 2026 12:16:48 +0200 Subject: [PATCH 4/5] Address review: use ByteString.Span/IsEmpty instead of temp byte[] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReadCoreAsync: check ByteString.IsEmpty for EOF before allocating; copy via data.Span.CopyTo(buffer.AsSpan(...)) instead of materialising a temp byte[] via ToArray() + Buffer.BlockCopy. WriteCoreAsync: same optimization on the symmetric path — wrap the caller's slice via the zero-copy 'new ByteString(buffer.AsMemory(offset, length))' constructor. The encoder copies the bytes onto the wire during WriteAsync; the await guarantees the buffer is not reused before the request is fully serialised, so wrapping (without an explicit copy) is safe. All 111 FileSystem unit tests still pass on net10.0. Addresses feedback from @marcschier on https://github.com/OPCFoundation/UA-.NETStandard/pull/3760#discussion_r Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Opc.Ua.Client/FileSystem/UaFileStream.cs | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/Libraries/Opc.Ua.Client/FileSystem/UaFileStream.cs b/Libraries/Opc.Ua.Client/FileSystem/UaFileStream.cs index fc55461559..b1fd92e8c2 100644 --- a/Libraries/Opc.Ua.Client/FileSystem/UaFileStream.cs +++ b/Libraries/Opc.Ua.Client/FileSystem/UaFileStream.cs @@ -401,22 +401,25 @@ private async Task ReadCoreAsync( chunkLen, ct).ConfigureAwait(false); - byte[] payload = data.ToArray() ?? []; - if (payload.Length == 0) + // Empty payload = EOF: short-circuit before touching + // the caller's buffer (or allocating anything). + if (data.IsEmpty) { break; } - Buffer.BlockCopy(payload, 0, buffer, offset + total, payload.Length); - total += payload.Length; - m_position += payload.Length; + int read = data.Length; + data.Span.CopyTo(buffer.AsSpan(offset + total, read)); + total += read; + m_position += read; m_serverPosition = m_position; if (m_position > m_length) { m_length = m_position; } - if (payload.Length < chunkLen) + if (read < chunkLen) { + // Short read = EOF on most servers. break; } } @@ -454,11 +457,14 @@ private async Task WriteCoreAsync( while (written < count) { int chunkLen = Math.Min(m_chunkSize, count - written); - var slice = new byte[chunkLen]; - Buffer.BlockCopy(buffer, offset + written, slice, 0, chunkLen); + // Wrap the caller's slice directly — the encoder + // copies it onto the wire during WriteAsync, and we + // await before reusing the underlying buffer. + var chunk = new ByteString( + buffer.AsMemory(offset + written, chunkLen)); await m_proxy.WriteAsync( Handle, - slice.ToByteString(), + chunk, ct).ConfigureAwait(false); written += chunkLen; m_position += chunkLen; From cbe9a8c0e91ff8d52c50730e057b3877bf4b82cf Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Thu, 14 May 2026 16:33:05 +0200 Subject: [PATCH 5/5] Refactor CertificateCacheTests by removing legacy tests Removed conditional compilation for older platforms and the associated NoOp test. --- .../CertificateCacheTests.cs | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/Tests/Opc.Ua.Core.Tests/Security/Certificates/CertificateManager/CertificateCacheTests.cs b/Tests/Opc.Ua.Core.Tests/Security/Certificates/CertificateManager/CertificateCacheTests.cs index 5cb3616532..4ae8635e0d 100644 --- a/Tests/Opc.Ua.Core.Tests/Security/Certificates/CertificateManager/CertificateCacheTests.cs +++ b/Tests/Opc.Ua.Core.Tests/Security/Certificates/CertificateManager/CertificateCacheTests.cs @@ -56,8 +56,6 @@ public void OneTimeTearDown() (m_telemetry as IDisposable)?.Dispose(); } -#if NET6_0_OR_GREATER - [Test] public void SetAndTryGetPublicKeyCert() { @@ -261,24 +259,5 @@ public void RefCountingIsCorrect() stillCached.Dispose(); cert.Dispose(); } - -#else - - [Test] - public void NoOpOnOlderPlatforms() - { - using var cache = new CertificateCache(m_telemetry); - using Certificate cert = CertificateBuilder - .Create("CN=NoOpTest") - .SetRSAKeySize(2048) - .CreateForRSA(); - - // On older TFMs, Set is a no-op and TryGet always returns null - cache.Set(cert.Thumbprint, cert); - Certificate result = cache.TryGet(cert.Thumbprint); - Assert.That(result, Is.Null); - } - -#endif } }