Files
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# Description

Nullable reference types are now enabled once, in `common.props`, and
every project in the repository builds warning-clean with them.

**Configuration**
- `<Nullable>enable</Nullable>` lives in `common.props`. All per-project
`<Nullable>` settings are removed: 178 project files (enable,
annotations, and the `disable` in the three Aot test projects).
- 419 per-file `#nullable enable`/`restore` directives and the 4
`#nullable disable` directives are removed.
- Kept on purpose:
- The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type
Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`,
`PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the
project setting in generated code, so the directive is required there.
- `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against
the 1.5.378 packages and does not import the repository props.
- `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now
describe the central setting.

**Migration** (the 43 unset and 13 annotations-only projects, roughly
10k diagnostics)
- **Production code: annotated at the declarations.** Covers
`tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the
LDS sample.
- Members, parameters and returns are nullable where null actually
flows.
- Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`,
`IsMethodTypeDesign`).
- `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take
`params object?[]`. `Template.AddReplacement` takes `string?` (rendering
already skips null replacements).
- `IRegisteredServerStore.RegisterAsync` returns
`Task<RegistrationEntry?>`: it returns null when a registration is
removed.
- Dereferences backed by an invariant (for example, the validated model)
use `!`, so runtime behaviour is unchanged.
- **Behavioural changes in production code** (small, deliberate):
- `NodeSetToModelDesign` throws `InvalidOperationException` when the
file is not a NodeSet. Before, it threw a NullReferenceException on the
next line.
- `ModelDesignValidator` starts its lookup tables empty instead of null.
A redundant `if (m_nodes == null) return;` guard is gone.
- **Test code: minimal annotations.**
  - `?` on values that can be null, `!` where the test asserts non-null.
  - `= null!` for fixture members assigned in SetUp.
- `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the
`IEncodeable` constraint.
- A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`)
folded into the adjacent `IsNull`/`Count` checks.

**Notes for reviewers**
- The diff is large (~1,500 files) but almost entirely mechanical: `?`,
`!` and directive removal. The non-annotation changes are listed above.
- Known follow-up, not done here:
`IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay
non-nullable even though the validator returns null before it loads a
dictionary. Making them nullable would ripple through every generator
call site.

## Related Issues

- Fixes #4439

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

The "no new warnings" box covers net10.0 and net48: full non-incremental
builds of `UA.slnx`, `tools/SourceGeneration.slnx`,
`tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0
warnings and 0 errors under the repository's warnings-as-errors
settings. net9.0 and net8.0 also had no nullable diagnostics, but they
were not rebuilt after the final fixes. Tests have not been run locally
yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:48:13 +02:00

999 lines
40 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;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Opc.Ua.Server.FileSystem;
using Opc.Ua.Server.Fluent;
using Opc.Ua.Server.Hosting;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests.FileSystem
{
/// <summary>
/// Tests for materialised FileDirectoryType bindings.
/// </summary>
[TestFixture]
[Category("FileSystem")]
public class FileDirectoryBinderTests
{
[Test]
public async Task BindRegistersTheCompletePreexistingTreeAsync()
{
InMemoryFileSystemProvider provider = CreateProvider(isWritable: false);
provider.AddFile("readme.txt", "hello");
provider.AddDirectory("programs");
provider.AddFile("programs/main.mod", "movej");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
var registered = new List<NodeState>();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root,
provider,
context,
registerNode: (node, _) =>
{
registered.Add(node);
return default;
}).ConfigureAwait(false);
Assert.That(binding.Directory, Is.SameAs(root));
Assert.That(binding.Provider, Is.SameAs(provider));
FileState readme = Find<FileState>(root, context, "readme.txt")!;
FileDirectoryState programs = Find<FileDirectoryState>(root, context, "programs")!;
FileState main = Find<FileState>(programs, context, "main.mod")!;
Assert.That(registered, Is.EquivalentTo(new NodeState[] { readme, programs, main }));
OpenMethodStateResult opened = await main.Open!.OnCallAsync!(
context, main.Open, main.NodeId, 1, CancellationToken.None).ConfigureAwait(false);
Assert.That(opened.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(main.Close!.OnCall!(context, main.Close, main.NodeId, opened.FileHandle).StatusCode,
Is.EqualTo(StatusCodes.Good));
}
[Test]
public async Task ReadFileReturnsProviderContentAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddFile("program.mod", "movej");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context).ConfigureAwait(false);
FileState file = Find<FileState>(root, context, "program.mod")!;
byte[] content = ReadAll(context, file);
Assert.That(Encoding.UTF8.GetString(content), Is.EqualTo("movej"));
}
[Test]
public async Task WriteFileUpdatesProviderContentAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddFile("program.mod", "old");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context).ConfigureAwait(false);
FileState file = Find<FileState>(root, context, "program.mod")!;
WriteAll(context, file, "new-content");
Assert.That(provider.ReadText("program.mod"), Is.EqualTo("new-content"));
}
[Test]
public async Task CreateReturnsAccessDeniedWhenProviderIsReadOnlyAsync()
{
InMemoryFileSystemProvider provider = CreateProvider(isWritable: false);
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context).ConfigureAwait(false);
CreateFileMethodStateResult result = await root.CreateFile!.OnCallAsync!(
context, root.CreateFile!, root.NodeId, "denied.txt", false, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(result.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
[Test]
public async Task CreateReturnsAccessDeniedWhenOptionsWithholdCreateAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
var options = new FileDirectoryBindingOptions { AllowCreate = false };
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context, options).ConfigureAwait(false);
CreateDirectoryMethodStateResult result = await root.CreateDirectory!.OnCallAsync!(
context, root.CreateDirectory!, root.NodeId, "denied", CancellationToken.None)
.ConfigureAwait(false);
Assert.That(result.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
[TestCase(false)]
[TestCase(true)]
public async Task CreateDirectoryRejectsExistingEntryWithIdempotentProviderAsync(bool existingFile)
{
InMemoryFileSystemProvider provider = CreateProvider();
if (existingFile)
{
provider.AddFile("existing", "payload");
}
else
{
provider.AddDirectory("existing");
provider.AddFile("existing/payload.txt", "payload");
}
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context).ConfigureAwait(false);
BaseInstanceState existing = Find<BaseInstanceState>(root, context, "existing")!;
CreateDirectoryMethodStateResult result = await root.CreateDirectory!.OnCallAsync!(
context, root.CreateDirectory, root.NodeId, "existing", CancellationToken.None).ConfigureAwait(false);
Assert.That(result.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameDuplicated));
Assert.That(result.DirectoryNodeId.IsNull, Is.True);
Assert.That(Find<BaseInstanceState>(root, context, "existing"), Is.SameAs(existing));
Assert.That(provider.ReadText(existingFile ? "existing" : "existing/payload.txt"), Is.EqualTo("payload"));
}
[TestCaseSource(nameof(s_invalidEntryNames))]
public async Task CreateRejectsANameThatIsNotASingleSegmentAsync(string name)
{
InMemoryFileSystemProvider provider = CreateProvider();
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context).ConfigureAwait(false);
CreateDirectoryMethodStateResult directoryResult =
await root.CreateDirectory!.OnCallAsync!(
context, root.CreateDirectory!, root.NodeId, name, CancellationToken.None)
.ConfigureAwait(false);
CreateFileMethodStateResult fileResult = await root.CreateFile!.OnCallAsync!(
context, root.CreateFile!, root.NodeId, name, false, CancellationToken.None)
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(
directoryResult.ServiceResult.StatusCode,
Is.EqualTo(StatusCodes.BadInvalidArgument));
Assert.That(
fileResult.ServiceResult.StatusCode,
Is.EqualTo(StatusCodes.BadInvalidArgument));
});
}
[Test]
public async Task MoveOrCopyRejectsANewNameThatIsNotASingleSegmentAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddDirectory("target");
provider.AddFile("old.txt", "x");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context).ConfigureAwait(false);
NodeId sourceId = Find<FileState>(root, context, "old.txt")!.NodeId;
NodeId targetId = Find<FileDirectoryState>(root, context, "target")!.NodeId;
MoveOrCopyMethodStateResult result = await root.MoveOrCopy!.OnCallAsync!(
context,
root.MoveOrCopy!,
root.NodeId,
sourceId,
targetId,
false,
Path.DirectorySeparatorChar == '\\' ? "..\\..\\escape" : "../../escape",
CancellationToken.None).ConfigureAwait(false);
Assert.That(
result.ServiceResult.StatusCode,
Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Test]
public async Task CreateDeleteAndMoveReconcileMaterialisedNodesAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddDirectory("target");
provider.AddFile("old.txt", "x");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
var registered = new List<NodeState>();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root,
provider,
context,
registerNode: (node, _) =>
{
registered.Add(node);
return default;
}).ConfigureAwait(false);
CreateFileMethodStateResult createResult = await root.CreateFile!.OnCallAsync!(
context, root.CreateFile!, root.NodeId, "new.txt", false, CancellationToken.None)
.ConfigureAwait(false);
FileDirectoryState target = Find<FileDirectoryState>(root, context, "target")!;
FileState oldFile = Find<FileState>(root, context, "old.txt")!;
MoveOrCopyMethodStateResult moveResult = await root.MoveOrCopy!.OnCallAsync!(
context, root.MoveOrCopy!, root.NodeId, oldFile.NodeId, target.NodeId, false, "moved.txt",
CancellationToken.None).ConfigureAwait(false);
FileState newFile = Find<FileState>(root, context, "new.txt")!;
DeleteFileMethodStateResult deleteResult = await root.DeleteFileSystemObject!.OnCallAsync!(
context, root.DeleteFileSystemObject!, root.NodeId, newFile.NodeId, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(createResult.ServiceResult), Is.True);
Assert.That(ServiceResult.IsGood(moveResult.ServiceResult), Is.True);
Assert.That(ServiceResult.IsGood(deleteResult.ServiceResult), Is.True);
Assert.That(provider.Exists("new.txt"), Is.False);
Assert.That(provider.Exists("target/moved.txt"), Is.True);
Assert.That(Find<FileState>(root, context, "new.txt"), Is.Null);
Assert.That(Find<FileState>(target, context, "moved.txt"), Is.Not.Null);
Assert.That(registered, Is.Not.Empty);
}
[Test]
public async Task RefreshDeregistersRemovedAndMovedMaterialisedNodesAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddDirectory("target");
provider.AddFile("old.txt", "x");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
var deregistered = new List<NodeState>();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root,
provider,
context,
options: null,
registerNode: null,
deregisterNode: (node, _) =>
{
deregistered.Add(node);
return default;
},
cancellationToken: CancellationToken.None).ConfigureAwait(false);
provider.Delete("old.txt");
await binding.RefreshAsync().ConfigureAwait(false);
Assert.That(deregistered.Select(node => node.BrowseName.Name), Does.Contain("old.txt"));
Assert.That(Find<FileState>(root, context, "old.txt"), Is.Null);
}
[Test]
public void MaxEntriesThrowsBeforeMaterialisingOverflow()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddFile("a.txt", "a");
provider.AddFile("b.txt", "b");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
var options = new FileDirectoryBindingOptions { MaxEntries = 1 };
Assert.That(
async () => await CreateBinder().BindAsync(root, provider, context, options).ConfigureAwait(false),
Throws.TypeOf<ServiceResultException>());
Assert.That(Find<FileState>(root, context, "a.txt"), Is.Null);
}
[Test]
public async Task MaxDepthStopsNestedMaterialisationAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddDirectory("level1");
provider.AddFile("level1/deep.txt", "deep");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
var options = new FileDirectoryBindingOptions { MaxDepth = 1 };
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context, options).ConfigureAwait(false);
FileDirectoryState level1 = Find<FileDirectoryState>(root, context, "level1")!;
Assert.That(level1, Is.Not.Null);
Assert.That(Find<FileState>(level1, context, "deep.txt"), Is.Null);
}
[Test]
public async Task RefreshAddsAndRemovesChildrenAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddFile("old.txt", "old");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
await using IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context).ConfigureAwait(false);
provider.Delete("old.txt");
provider.AddFile("new.txt", "new");
await binding.RefreshAsync().ConfigureAwait(false);
Assert.That(Find<FileState>(root, context, "old.txt"), Is.Null);
Assert.That(Find<FileState>(root, context, "new.txt"), Is.Not.Null);
}
[Test]
public async Task DisposeClosesHandlesAndDetachesCallbacksAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddFile("open.txt", "content");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
IFileDirectoryBinding binding = await CreateBinder().BindAsync(root, provider, context).ConfigureAwait(false);
FileState file = Find<FileState>(root, context, "open.txt")!;
_ = Open(context, file, 0x1);
await binding.DisposeAsync().ConfigureAwait(false);
Assert.That(provider.OpenStreamCount, Is.Zero);
Assert.That(file.Close!.OnCall, Is.Null);
Assert.That(root.CreateFile!.OnCallAsync!, Is.Null);
}
[Test]
public async Task DisposeDeregistersEveryRegisteredNodeAndDetachesCallbacksAsync()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddDirectory("programs");
provider.AddFile("programs/main.mod", "movej");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
var registered = new List<NodeState>();
var deregistered = new List<NodeState>();
IFileDirectoryBinding binding = await CreateBinder().BindAsync(
root, provider, context,
registerNode: (node, _) =>
{
registered.Add(node);
return default;
},
deregisterNode: (node, _) =>
{
deregistered.Add(node);
return default;
}).ConfigureAwait(false);
FileDirectoryState programs = Find<FileDirectoryState>(root, context, "programs")!;
FileState main = Find<FileState>(programs, context, "main.mod")!;
OpenMethodStateResult opened = await main.Open!.OnCallAsync!(
context, main.Open, main.NodeId, 1, CancellationToken.None).ConfigureAwait(false);
Assert.That(opened.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.Good));
await binding.DisposeAsync().ConfigureAwait(false);
await binding.DisposeAsync().ConfigureAwait(false);
Assert.That(registered, Has.Count.EqualTo(2));
Assert.That(deregistered, Is.EquivalentTo(registered));
Assert.That(deregistered[0], Is.SameAs(main));
Assert.That(provider.OpenStreamCount, Is.Zero);
Assert.That(root.CreateFile!.OnCallAsync, Is.Null);
Assert.That(programs.CreateFile!.OnCallAsync, Is.Null);
Assert.That(main.Open.OnCallAsync, Is.Null);
Assert.That(main.Size!.OnReadValue, Is.Null);
Assert.That(main.Size.OnReadValueAsync, Is.Null);
Assert.That(Find<FileDirectoryState>(root, context, "programs"), Is.Null);
}
[Test]
public void FailedInitialRegistrationDeregistersOnlyCompletedRegistrations()
{
InMemoryFileSystemProvider provider = CreateProvider();
provider.AddFile("a.txt", "a");
provider.AddFile("b.txt", "b");
FileDirectoryState root = CreateRoot();
SessionSystemContext context = CreateContext();
var deregistered = new List<NodeState>();
Assert.ThrowsAsync<IOException>(async () => await CreateBinder().BindAsync(
root, provider, context,
registerNode: (node, _) =>
{
if (node.BrowseName.Name == "b.txt")
{
throw new IOException("Registration rejected.");
}
return default;
},
deregisterNode: (node, _) =>
{
deregistered.Add(node);
return default;
}).ConfigureAwait(false));
Assert.That(deregistered, Has.Count.EqualTo(1));
Assert.That(deregistered[0].BrowseName.Name, Is.EqualTo("a.txt"));
Assert.That(Find<FileState>(root, context, "a.txt"), Is.Null);
Assert.That(Find<FileState>(root, context, "b.txt"), Is.Null);
Assert.That(root.CreateFile!.OnCallAsync, Is.Null);
}
[Test]
public void AddFileDirectoryBinderRegistersDefaultBinder()
{
var services = new ServiceCollection();
services.AddSingleton(NUnitTelemetryContext.Create());
IOpcUaServerBuilder builder = new TestServerBuilder(services);
builder.AddFileDirectoryBinder();
using ServiceProvider provider = services.BuildServiceProvider();
Assert.That(provider.GetService<IFileDirectoryBinder>(), Is.TypeOf<FileDirectoryBinder>());
}
private static FileDirectoryBinder CreateBinder()
{
return new FileDirectoryBinder();
}
private static SessionSystemContext CreateContext()
{
var namespaceUris = new NamespaceTable();
namespaceUris.Append("http://opcfoundation.org/UA/");
namespaceUris.Append("urn:test");
return new SessionSystemContext(NUnitTelemetryContext.Create())
{
NamespaceUris = namespaceUris,
ServerUris = new StringTable(),
TypeTable = new TypeTable(namespaceUris),
SessionId = new NodeId("test-session", 0)
};
}
private static FileDirectoryState CreateRoot()
{
return new FileDirectoryState(null)
{
TypeDefinitionId = ObjectTypeIds.FileDirectoryType,
NodeId = new NodeId("Programs", 2),
BrowseName = new QualifiedName("Programs", 2),
DisplayName = new LocalizedText("Programs")
};
}
private static T? Find<T>(FileDirectoryState directory, ISystemContext context, string browseName)
where T : BaseInstanceState
{
return directory.FindChild(context, new QualifiedName(browseName, directory.BrowseName.NamespaceIndex)) as T;
}
private static uint Open(ISystemContext context, FileState file, byte mode)
{
uint handle = 0;
ServiceResult result = file.Open!.OnCall!(context, file.Open!, file.NodeId, mode, ref handle);
Assert.That(ServiceResult.IsGood(result), Is.True);
return handle;
}
private static byte[] ReadAll(ISystemContext context, FileState file)
{
uint handle = Open(context, file, 0x1);
var data = ByteString.From([]);
ServiceResult readResult = file.Read!.OnCall!(context, file.Read!, file.NodeId, handle, 1024, ref data);
ServiceResult closeResult = file.Close!.OnCall!(context, file.Close!, file.NodeId, handle);
Assert.That(ServiceResult.IsGood(readResult), Is.True);
Assert.That(ServiceResult.IsGood(closeResult), Is.True);
return data.ToArray();
}
private static void WriteAll(ISystemContext context, FileState file, string text)
{
uint handle = Open(context, file, 0x6);
ServiceResult writeResult = file.Write!.OnCall!(
context, file.Write!, file.NodeId, handle, ByteString.From(Encoding.UTF8.GetBytes(text)));
ServiceResult closeResult = file.Close!.OnCall!(context, file.Close!, file.NodeId, handle);
Assert.That(ServiceResult.IsGood(writeResult), Is.True);
Assert.That(ServiceResult.IsGood(closeResult), Is.True);
}
private static InMemoryFileSystemProvider CreateProvider(bool isWritable = true)
{
return new InMemoryFileSystemProvider(isWritable);
}
private static readonly string[] s_invalidEntryNames = Path.DirectorySeparatorChar == '\\'
? ["../escape", "..\\escape", "sub/child", "sub\\child", "/rooted", "\\rooted",
"C:\\Windows\\System32\\evil", "..", ".", "stream:name", " "]
: ["../escape", "sub/child", "/rooted", "..", ".", " "];
private sealed class TestServerBuilder : IOpcUaServerBuilder
{
public TestServerBuilder(IServiceCollection services)
{
Services = services;
}
public IServiceCollection Services { get; }
public IOpcUaServerBuilder AddNodeManager<TFactory>()
where TFactory : class, IAsyncNodeManagerFactory
{
throw new NotSupportedException();
}
public IOpcUaServerBuilder AddNodeManager(string namespaceUri, Action<INodeManagerBuilder> build)
{
throw new NotSupportedException();
}
public IOpcUaServerBuilder AddSyncNodeManager<TFactory>()
where TFactory : class, INodeManagerFactory
{
throw new NotSupportedException();
}
}
private sealed class InMemoryFileSystemProvider : IFileSystemProvider
{
public InMemoryFileSystemProvider(bool isWritable)
{
IsWritable = isWritable;
m_entries[string.Empty] = Entry.Directory("Programs");
}
public string MountName => "Programs";
public bool IsWritable { get; }
public int OpenStreamCount
{
get
{
lock (m_lock)
{
return m_openStreamCount;
}
}
}
public ValueTask<FileSystemEntry?> GetEntryAsync(string path, CancellationToken ct)
{
lock (m_lock)
{
return new ValueTask<FileSystemEntry?>(
m_entries.TryGetValue(Normalize(path), out Entry? entry)
? entry.ToFileSystemEntry(Normalize(path), IsWritable)
: null);
}
}
public async IAsyncEnumerable<FileSystemEntry> EnumerateAsync(
string path,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
await Task.CompletedTask.ConfigureAwait(false);
List<FileSystemEntry> entries;
string parent = Normalize(path);
lock (m_lock)
{
if (!m_entries.TryGetValue(parent, out Entry? parentEntry) || !parentEntry.IsDirectory)
{
throw new DirectoryNotFoundException(parent);
}
entries = [.. m_entries
.Where(kv => IsImmediateChild(parent, kv.Key))
.OrderBy(kv => kv.Key, StringComparer.Ordinal)
.Select(kv => kv.Value.ToFileSystemEntry(kv.Key, IsWritable))];
}
foreach (FileSystemEntry entry in entries)
{
ct.ThrowIfCancellationRequested();
yield return entry;
}
}
public ValueTask<Stream> OpenReadAsync(string path, CancellationToken ct)
{
lock (m_lock)
{
string normalized = Normalize(path);
if (!m_entries.TryGetValue(normalized, out Entry? entry) || entry.IsDirectory)
{
throw new FileNotFoundException(normalized);
}
m_openStreamCount++;
return new ValueTask<Stream>(new TrackingReadStream(
[.. entry.Content],
() =>
{
lock (m_lock)
{
m_openStreamCount--;
}
}));
}
}
public ValueTask<Stream> OpenWriteAsync(string path, FileWriteMode mode, CancellationToken ct)
{
if (!IsWritable)
{
throw new UnauthorizedAccessException();
}
lock (m_lock)
{
string normalized = Normalize(path);
byte[] initial = [];
if (m_entries.TryGetValue(normalized, out Entry? entry))
{
if (entry.IsDirectory)
{
throw new IOException(normalized);
}
if (mode == FileWriteMode.Append)
{
initial = [.. entry.Content];
}
}
else
{
EnsureParentDirectory(normalized);
m_entries[normalized] = Entry.File([]);
}
var stream = new CommitMemoryStream(bytes => Commit(normalized, bytes));
stream.Write(initial, 0, initial.Length);
if (mode == FileWriteMode.Append)
{
stream.Position = stream.Length;
}
else
{
stream.SetLength(0);
}
return new ValueTask<Stream>(stream);
}
}
public ValueTask CreateDirectoryAsync(string path, CancellationToken ct)
{
if (!IsWritable)
{
throw new UnauthorizedAccessException();
}
lock (m_lock)
{
AddDirectory(Normalize(path));
}
return default;
}
public ValueTask CreateFileAsync(string path, CancellationToken ct)
{
if (!IsWritable)
{
throw new UnauthorizedAccessException();
}
lock (m_lock)
{
string normalized = Normalize(path);
if (m_entries.ContainsKey(normalized))
{
throw new IOException(normalized);
}
EnsureParentDirectory(normalized);
m_entries[normalized] = Entry.File([]);
}
return default;
}
public ValueTask DeleteAsync(string path, CancellationToken ct)
{
if (!IsWritable)
{
throw new UnauthorizedAccessException();
}
lock (m_lock)
{
Delete(Normalize(path));
}
return default;
}
public ValueTask MoveAsync(string source, string target, CancellationToken ct)
{
if (!IsWritable)
{
throw new UnauthorizedAccessException();
}
lock (m_lock)
{
CopyCore(Normalize(source), Normalize(target));
Delete(Normalize(source));
}
return default;
}
public ValueTask CopyAsync(string source, string target, CancellationToken ct)
{
if (!IsWritable)
{
throw new UnauthorizedAccessException();
}
lock (m_lock)
{
CopyCore(Normalize(source), Normalize(target));
}
return default;
}
public void AddDirectory(string path)
{
string normalized = Normalize(path);
EnsureParentDirectory(normalized);
if (m_entries.TryGetValue(normalized, out Entry? existing) && !existing.IsDirectory)
{
throw new IOException(normalized);
}
m_entries[normalized] = Entry.Directory(NameOf(normalized));
}
public void AddFile(string path, string content)
{
string normalized = Normalize(path);
EnsureParentDirectory(normalized);
m_entries[normalized] = Entry.File(Encoding.UTF8.GetBytes(content));
}
public bool Exists(string path)
{
lock (m_lock)
{
return m_entries.ContainsKey(Normalize(path));
}
}
public string ReadText(string path)
{
lock (m_lock)
{
return Encoding.UTF8.GetString(m_entries[Normalize(path)].Content);
}
}
public void Delete(string path)
{
string normalized = Normalize(path);
if (string.IsNullOrEmpty(normalized) || !m_entries.ContainsKey(normalized))
{
throw new FileNotFoundException(normalized);
}
string prefix = normalized + "/";
foreach (string key in m_entries.Keys.Where(key => key == normalized ||
key.StartsWith(prefix, StringComparison.Ordinal)).ToList())
{
m_entries.Remove(key);
}
}
private void Commit(string path, byte[] bytes)
{
lock (m_lock)
{
m_entries[path] = Entry.File(bytes);
}
}
private void CopyCore(string source, string target)
{
if (!m_entries.TryGetValue(source, out Entry? sourceEntry))
{
throw new FileNotFoundException(source);
}
if (m_entries.ContainsKey(target))
{
throw new IOException(target);
}
EnsureParentDirectory(target);
m_entries[target] = sourceEntry.Clone(NameOf(target));
string sourcePrefix = source + "/";
foreach (KeyValuePair<string, Entry> kv in m_entries.ToArray())
{
if (kv.Key.StartsWith(sourcePrefix, StringComparison.Ordinal))
{
string suffix = kv.Key[sourcePrefix.Length..];
m_entries[target + "/" + suffix] = kv.Value.Clone(NameOf(suffix));
}
}
}
private void EnsureParentDirectory(string path)
{
int slash = path.LastIndexOf('/');
if (slash < 0)
{
return;
}
string parent = path[..slash];
if (!m_entries.TryGetValue(parent, out Entry? entry) || !entry.IsDirectory)
{
throw new DirectoryNotFoundException(parent);
}
}
private static bool IsImmediateChild(string parent, string candidate)
{
if (string.IsNullOrEmpty(candidate))
{
return false;
}
if (string.IsNullOrEmpty(parent))
{
return !candidate.Contains('/', StringComparison.Ordinal);
}
if (!candidate.StartsWith(parent + "/", StringComparison.Ordinal))
{
return false;
}
return !candidate[(parent.Length + 1)..].Contains('/', StringComparison.Ordinal);
}
private static string NameOf(string path)
{
int slash = path.LastIndexOf('/');
return slash < 0 ? path : path[(slash + 1)..];
}
private static string Normalize(string path)
{
return string.IsNullOrEmpty(path) || path == "/" ? string.Empty : path.Trim('/');
}
private readonly Dictionary<string, Entry> m_entries = new(StringComparer.Ordinal);
private readonly Lock m_lock = new();
private int m_openStreamCount;
private sealed class CommitMemoryStream : MemoryStream
{
public CommitMemoryStream(Action<byte[]> commit)
{
m_commit = commit;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
m_commit(ToArray());
}
base.Dispose(disposing);
}
private readonly Action<byte[]> m_commit;
}
private sealed class TrackingReadStream : MemoryStream
{
public TrackingReadStream(byte[] buffer, Action onDispose)
: base(buffer, writable: false)
{
m_onDispose = onDispose;
}
protected override void Dispose(bool disposing)
{
if (disposing && !m_disposed)
{
m_disposed = true;
m_onDispose();
}
base.Dispose(disposing);
}
private readonly Action m_onDispose;
private bool m_disposed;
}
private sealed class Entry
{
private Entry(string name, bool isDirectory, byte[] content)
{
Name = name;
IsDirectory = isDirectory;
Content = content;
LastModifiedUtc = DateTime.UtcNow;
}
public string Name { get; }
public bool IsDirectory { get; }
public byte[] Content { get; }
public DateTime LastModifiedUtc { get; }
public static Entry Directory(string name)
{
return new Entry(name, isDirectory: true, []);
}
public static Entry File(byte[] content)
{
return new Entry(string.Empty, isDirectory: false, content);
}
public Entry Clone(string name)
{
return IsDirectory ? Directory(name) : File([.. Content]);
}
public FileSystemEntry ToFileSystemEntry(string path, bool isWritable)
{
return new FileSystemEntry(
path,
string.IsNullOrEmpty(path) ? Name : NameOf(path),
IsDirectory,
Content.Length,
isWritable,
LastModifiedUtc,
IsDirectory ? string.Empty : "text/plain");
}
}
}
}
}