mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43:08 +08:00
9b034f8291
# 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>
999 lines
40 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|