Files
Marc Schier a193a0a278 Add OPC UA Server FileSystem (Part 20) and Local Discovery Server libraries (#3776)
This change introduces two independent additions to the .NET Standard
stack:

## `Libraries/Opc.Ua.Server.FileSystem` (Part 5 §C / Part 20 §4)

Server-side implementation of the OPC UA `FileType` /
`FileDirectoryType` companion model, mirroring the existing client
namespace `Libraries/Opc.Ua.Client.FileSystem`.

### Provider abstraction

- `IFileSystemProvider` — async storage abstraction
  (`GetEntryAsync` / `EnumerateAsync` / `OpenRead/WriteAsync` /
  `Create{Directory,File}Async` / `DeleteAsync` / `MoveAsync` /
  `CopyAsync`). All paths are forward-slash provider-relative; the
  empty string is the root.
- `FileSystemEntry` record + `FileWriteMode` enum.

### Default `PhysicalFileSystemProvider`

`System.IO`-based implementation that mounts a **single physical
directory** as the FileSystem root. Rejects path-traversal attempts
(`..` escape) via `Path.GetFullPath` + root-prefix check. Supports
an optional `isWritable: false` mode for read-only mounts.

### Node manager

`FileSystemNodeManager` drives every Browse / Read / Method call
through the configured provider. Each mounted provider is anchored
as a `FileDirectoryType` instance under the standard
`Server.FileSystem` object (`ObjectIds.FileSystem`, `i=16314`)
via `HasComponent`, which is what
`FileSystemClient.OpenServerFileSystem` expects to find. Mount
multiple providers by registering multiple
`FileSystemNodeManager` instances. Companion files
(`DirectoryBrowser`, `DirectoryObjectState`, `FileObjectState`,
`FileHandle`, `FileSystemNodeId`, `FileSystemNodeManagerFactory`)
are all provider-driven.

### Client-side fix

`Libraries/Opc.Ua.Client.FileSystem.FileSystemClient.EnumerateChildrenAsync`
now stops the BrowseNext loop on `continuation.Length == 0` in
addition to `continuation.IsNull`. Without this fix, an
empty-but-non-null ByteString continuation returned by the SDK after
wire round-trip would cause a spurious BrowseNext and fail with
`BadContinuationPointInvalid`.

### Reference-server hook

`Quickstarts.ReferenceServer.ReferenceServer` gains two opt-in
properties (default off):
- `EnableFileSystemNodeManager` — toggles whether the FS node
  manager is registered.
- `FileSystemProvider` — caller-supplied
  `IFileSystemProvider`. When `null` and the flag is on, falls
  back to a `PhysicalFileSystemProvider` rooted at
  `%TEMP%/OpcUaReferenceServerFs` (mount name `Temp`).

### Tests

`Tests/Opc.Ua.Client.Tests/FileSystem/FileSystemClientIntegrationTests.cs`
— 12 end-to-end tests over a real
`ServerFixture<ReferenceServer>` + `ClientFixture` (opc.tcp,
SecurityNone, temp-dir provider): round-trip write/read,
enumeration, nested `CreateDirectory` / `CreateSubdirectory`,
delete, move, copy, large-file chunked read, mount BrowseName,
missing path returns null, multi-level enumeration, text round-trip,
read-only provider rejects write.

## `Libraries/Opc.Ua.Lds.Server`

A new Local Discovery Server (LDS) library implementing OPC UA Part
12 §7:
- `LdsServer` — main server class.
- `MulticastDiscovery` — DNS-SD / mDNS bootstrap via
  `Makaretu.Dns.Multicast` (version 0.27.0 added to
  `Directory.Packages.props`).
- `RegisteredServerStore` — in-memory store with TTL.
- `RegistrationEntry` + `ServerOnNetworkRecord` — value types.

Comes with a sample host `Applications/ConsoleLdsServer`.

## Build / test
- `dotnet build UA.slnx -c Release` -> 0 errors on all target
  frameworks (net472, net48, netstandard2.1, net8.0, net9.0, net10.0).
- `Opc.Ua.Server.Tests` -> 964 pass, 5 skip, 0 fail.
- `FileSystemClientIntegrationTests` -> 12/12 pass.
2026-05-17 21:31:47 +02:00

181 lines
6.1 KiB
C#

/* ========================================================================
* 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.Collections.Generic;
using System.Threading;
namespace Opc.Ua.Server.FileSystem
{
/// <summary>
/// Enumerates the immediate children (files + directories) of a
/// <see cref="DirectoryObjectState"/> via the underlying
/// <see cref="IFileSystemProvider"/>.
/// </summary>
internal sealed class DirectoryBrowser : NodeBrowser
{
public DirectoryBrowser(
ISystemContext context, ViewDescription? view,
NodeId referenceType, bool includeSubtypes, BrowseDirection browseDirection,
QualifiedName browseName, IEnumerable<IReference>? additionalReferences,
bool internalOnly,
FileSystemNodeManager manager,
DirectoryObjectState source)
: base(context, view, referenceType, includeSubtypes, browseDirection,
browseName, additionalReferences, internalOnly)
{
m_manager = manager;
m_source = source;
m_stage = Stage.Begin;
}
public override IReference? Next()
{
lock (DataLock)
{
IReference? reference = base.Next();
if (reference != null)
{
return reference;
}
if (InternalOnly || m_manager == null)
{
return null;
}
if (!IsRequired(ReferenceTypeIds.HasComponent, false))
{
return null;
}
if (m_stage == Stage.Begin)
{
m_pending = LoadEntries();
m_stage = Stage.Children;
}
if (m_stage == Stage.Children)
{
reference = NextChild();
if (reference != null)
{
return reference;
}
m_stage = Stage.Done;
}
return null;
}
}
private List<FileSystemEntry> LoadEntries()
{
var list = new List<FileSystemEntry>();
try
{
IAsyncEnumerator<FileSystemEntry> enumerator = m_manager.Provider
.EnumerateAsync(m_source.ProviderPath, CancellationToken.None)
.GetAsyncEnumerator(CancellationToken.None);
try
{
while (enumerator.MoveNextAsync().AsTask().GetAwaiter().GetResult())
{
list.Add(enumerator.Current);
}
}
finally
{
enumerator.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
}
catch
{
// Browse continues without children when the provider
// can't enumerate (e.g. permission denied).
}
return list;
}
private NodeStateReference? NextChild()
{
if (m_pending == null)
{
return null;
}
// Named child requested — scan once and stop.
if (!BrowseName.IsNull)
{
if (m_source.BrowseName.NamespaceIndex != BrowseName.NamespaceIndex)
{
m_pending = null;
return null;
}
foreach (FileSystemEntry entry in m_pending)
{
if (entry.Name == BrowseName.Name)
{
m_pending = null;
return CreateReference(entry);
}
}
m_pending = null;
return null;
}
if (m_pending.Count == 0)
{
return null;
}
FileSystemEntry head = m_pending[0];
m_pending.RemoveAt(0);
return CreateReference(head);
}
private NodeStateReference CreateReference(FileSystemEntry entry)
{
NodeId targetId = entry.IsDirectory
? FileSystemNodeId.BuildDirectory(entry.Path, m_manager.NamespaceIndex)
: FileSystemNodeId.BuildFile(entry.Path, m_manager.NamespaceIndex);
return new NodeStateReference(ReferenceTypeIds.HasComponent, false, targetId);
}
private enum Stage
{
Begin,
Children,
Done
}
private readonly FileSystemNodeManager m_manager;
private readonly DirectoryObjectState m_source;
private List<FileSystemEntry>? m_pending;
private Stage m_stage;
}
}