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. 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..b1fd92e8c2 --- /dev/null +++ b/Libraries/Opc.Ua.Client/FileSystem/UaFileStream.cs @@ -0,0 +1,578 @@ +/* ======================================================================== + * 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); + + // Empty payload = EOF: short-circuit before touching + // the caller's buffer (or allocating anything). + if (data.IsEmpty) + { + break; + } + + 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 (read < chunkLen) + { + // Short read = EOF on most servers. + 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); + // 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, + chunk, + 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/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 } } 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 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 —