mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
e1b9cea3ea
## Proposed changes Reduces sync-over-async in non-obsolete server APIs, removes stale `#region/#endregion` noise from the test suite, and fixes analyzer/test fallout from merging the Part 11 Historical Access framework (#3797). Follow-up review feedback is included: `FileSystemNodeManager` is now based on `AsyncCustomNodeManager`. ### Sync-over-async reduction (~24 `GetAwaiter().GetResult()` sites) **Server-side:** - `TrustList`: async `OnCallAsync` handlers added; sync `OnCall` handlers retained as compat shims so legacy `CustomNodeManager2` subclasses that host the TrustList but do not implement `ICallAsyncNodeManager` keep working through `MethodState.Call`. - `GDS ApplicationsNodeManager`: `RequestAccessToken`, `KeyCredentialStart/FinishRequest`, and `Revoke` moved to `OnCallAsync`. - `IKeyCredentialRequestStore`: interface + in-memory implementation converted to async; `FinishKeyCredentialRequestResult` record carries the previous out-set. Tests in `Tests/Opc.Ua.Gds.Tests/` updated to the new async API. - `FileSystem`: - `DirectoryObjectState` methods (`CreateDirectory` / `CreateFile` / `Delete` / `MoveOrCopy`) use `OnCallAsync`. - `FileSystemNodeManager` now derives from `AsyncCustomNodeManager` and overrides async address-space/node-resolution paths (`CreateAddressSpaceAsync`, `DeleteAddressSpaceAsync`, `GetManagerHandleAsync`, `ValidateNodeAsync`) to avoid sync-over-async in dynamic node validation. - `FileSystemNodeManagerFactory` now supports async creation (`IAsyncNodeManagerFactory`) while retaining sync factory compatibility. - Reference server registration of the FileSystem node manager is aligned to the async node manager list. - `ConfigurationNodeManager` namespace lookup uses sync `GetManagerHandle` to keep the remaining `GetResult` inside the documented `SyncNodeManagerAdapter` bridge. - `ServerBase`: new `OnServerStartingAsync`; `StartAsync` now awaits it. - `StandardServer.CreateMasterNodeManagerAsync` added; sync wrapper kept for backward compatibility. - `IServerInternal.CloseSession` marked `[Obsolete]` (`CloseSessionAsync` exists). - `IMasterNodeManager.RemoveReferences` marked `[Obsolete]`; in-tree sync callers remain with scoped `CS0618` pragmas + TODO. **Stack:** - `EncryptedSecret`: added `VerifyHeaderForEccAsync` / `DecryptAsync` / `TryDecryptAsync`; `UserNameIdentityTokenHandler.DecryptAsync` awaits ECC validation. - `CertificatePasswordProvider` ctor: `GetResult` fallback replaced with explicit `InvalidOperationException`. ### Code-style and post-merge cleanup - Removed unused `#region`/`#endregion` directives across test files. - Restored green build state after Part 11 merge fallout fixes (production + analyzer + test cleanup from earlier commits in this PR). ### CI / validation notes - This PR branch surfaced runtime regressions introduced by Part 11 after the build-breaker was fixed (outside scope of this async/code-style cleanup). - Follow-up feedback change validation for `FileSystemNodeManager`: - `dotnet build Libraries/Opc.Ua.Server/Opc.Ua.Server.csproj -f net10.0 --no-restore` ✅ - `dotnet test Tests/Opc.Ua.Server.Tests/Opc.Ua.Server.Tests.csproj -f net10.0 --filter FileSystemNodeIdTests` ✅ - `dotnet test Tests/Opc.Ua.History.Tests/Opc.Ua.History.Tests.csproj -f net10.0 --filter FileSystemSmokeTests` ✅ (test skipped in environment) ## Related Issues - None. ## Types of changes - [ ] Bugfix (non-breaking change which fixes an issue) - [x] Enhancement (non-breaking change which adds functionality) - [x] Test enhancement (non-breaking change to increase test coverage) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected, requires version increase of Nuget packages) - [ ] Documentation Update (if none of the other choices apply) ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf). - [x] I ran tests locally with my changes, all passed. - [ ] I fixed all failing tests in the CI pipelines. - [ ] I fixed all introduced issues with CodeQL and LGTM. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added necessary documentation (if appropriate). - [x] Any dependent changes have been merged and published in downstream modules.
449 lines
18 KiB
C#
449 lines
18 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.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace Opc.Ua.Server.FileSystem
|
|
{
|
|
/// <summary>
|
|
/// Address-space representation of a single directory in an
|
|
/// <see cref="IFileSystemProvider"/>. Hangs the FileDirectoryType
|
|
/// methods (CreateFile / CreateDirectory / DeleteFileSystemObject
|
|
/// / MoveOrCopy) on top of the provider.
|
|
/// </summary>
|
|
internal sealed class DirectoryObjectState : FileDirectoryState
|
|
{
|
|
public string ProviderPath { get; }
|
|
|
|
public bool IsRoot { get; }
|
|
|
|
public DirectoryObjectState(
|
|
ISystemContext context,
|
|
NodeId nodeId,
|
|
string providerPath,
|
|
string displayName,
|
|
bool isRoot)
|
|
: base(null)
|
|
{
|
|
ProviderPath = providerPath ?? string.Empty;
|
|
IsRoot = isRoot;
|
|
|
|
TypeDefinitionId = ObjectTypeIds.FileDirectoryType;
|
|
SymbolicName = ProviderPath;
|
|
NodeId = nodeId;
|
|
BrowseName = new QualifiedName(displayName);
|
|
DisplayName = new LocalizedText(displayName);
|
|
Description = LocalizedText.Null;
|
|
WriteMask = 0;
|
|
UserWriteMask = 0;
|
|
EventNotifier = EventNotifiers.None;
|
|
|
|
DeleteFileSystemObject = new DeleteFileMethodState(this)
|
|
{
|
|
OnCallAsync = OnDeleteFileSystemObjectAsync,
|
|
Executable = true,
|
|
UserExecutable = true
|
|
};
|
|
DeleteFileSystemObject.Create(context, MethodIds.FileDirectoryType_DeleteFileSystemObject,
|
|
new QualifiedName(BrowseNames.DeleteFileSystemObject),
|
|
new LocalizedText(BrowseNames.DeleteFileSystemObject), false);
|
|
|
|
CreateFile = new CreateFileMethodState(this)
|
|
{
|
|
OnCallAsync = OnCreateFileAsync,
|
|
Executable = true,
|
|
UserExecutable = true
|
|
};
|
|
CreateFile.Create(context, MethodIds.FileDirectoryType_CreateFile,
|
|
new QualifiedName(BrowseNames.CreateFile),
|
|
new LocalizedText(BrowseNames.CreateFile), false);
|
|
|
|
CreateDirectory = new CreateDirectoryMethodState(this)
|
|
{
|
|
OnCallAsync = OnCreateDirectoryAsync,
|
|
Executable = true,
|
|
UserExecutable = true
|
|
};
|
|
CreateDirectory.Create(context, MethodIds.FileDirectoryType_CreateDirectory,
|
|
new QualifiedName(BrowseNames.CreateDirectory),
|
|
new LocalizedText(BrowseNames.CreateDirectory), false);
|
|
|
|
MoveOrCopy = new MoveOrCopyMethodState(this)
|
|
{
|
|
OnCallAsync = OnMoveOrCopyAsync,
|
|
Executable = true,
|
|
UserExecutable = true
|
|
};
|
|
MoveOrCopy.Create(context, MethodIds.FileDirectoryType_MoveOrCopy,
|
|
new QualifiedName(BrowseNames.MoveOrCopy),
|
|
new LocalizedText(BrowseNames.MoveOrCopy), false);
|
|
}
|
|
|
|
public override INodeBrowser CreateBrowser(
|
|
ISystemContext context, ViewDescription? view, NodeId referenceType,
|
|
bool includeSubtypes, BrowseDirection browseDirection,
|
|
QualifiedName browseName, IEnumerable<IReference>? additionalReferences,
|
|
bool internalOnly)
|
|
{
|
|
var manager = context?.SystemHandle as FileSystemNodeManager;
|
|
var browser = new DirectoryBrowser(
|
|
context!, view, referenceType, includeSubtypes,
|
|
browseDirection, browseName, additionalReferences,
|
|
internalOnly, manager!, this);
|
|
PopulateBrowser(context!, browser);
|
|
return browser;
|
|
}
|
|
|
|
protected override void PopulateBrowser(ISystemContext context, NodeBrowser browser)
|
|
{
|
|
base.PopulateBrowser(context, browser);
|
|
|
|
if (context?.SystemHandle is not FileSystemNodeManager manager)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Inverse reference to the parent: for the mount root we
|
|
// expose a HasComponent inverse to Server.FileSystem
|
|
// (i=16314). For nested directories the parent is another
|
|
// directory inside this provider.
|
|
if (browser.IsRequired(ReferenceTypeIds.HasComponent, true))
|
|
{
|
|
if (IsRoot)
|
|
{
|
|
browser.Add(ReferenceTypeIds.HasComponent, true, ObjectIds.FileSystem);
|
|
}
|
|
else
|
|
{
|
|
NodeId parentId = manager.GetParentNodeId(ProviderPath);
|
|
if (!parentId.IsNull)
|
|
{
|
|
browser.Add(ReferenceTypeIds.HasComponent, true, parentId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async ValueTask<CreateDirectoryMethodStateResult> OnCreateDirectoryAsync(
|
|
ISystemContext context, MethodState method, NodeId objectId,
|
|
string directoryName, CancellationToken cancellationToken)
|
|
{
|
|
if (context?.SystemHandle is not FileSystemNodeManager manager)
|
|
{
|
|
return new CreateDirectoryMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidState,
|
|
"Node manager unavailable.")
|
|
};
|
|
}
|
|
if (string.IsNullOrEmpty(directoryName))
|
|
{
|
|
return new CreateDirectoryMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidArgument,
|
|
"Directory name required.")
|
|
};
|
|
}
|
|
string newPath = manager.CombineProviderPath(ProviderPath, directoryName);
|
|
try
|
|
{
|
|
await manager.Provider.CreateDirectoryAsync(newPath, cancellationToken).ConfigureAwait(false);
|
|
return new CreateDirectoryMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Good,
|
|
DirectoryNodeId = FileSystemNodeId.BuildDirectory(newPath, manager.NamespaceIndex)
|
|
};
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return new CreateDirectoryMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied,
|
|
"Failed to create directory.")
|
|
};
|
|
}
|
|
catch (IOException ex)
|
|
{
|
|
return new CreateDirectoryMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(ex, StatusCodes.BadBrowseNameDuplicated,
|
|
"Directory or file with same name exists.")
|
|
};
|
|
}
|
|
}
|
|
|
|
private async ValueTask<CreateFileMethodStateResult> OnCreateFileAsync(
|
|
ISystemContext context, MethodState method, NodeId objectId,
|
|
string fileName, bool requestFileOpen, CancellationToken cancellationToken)
|
|
{
|
|
if (context?.SystemHandle is not FileSystemNodeManager manager)
|
|
{
|
|
return new CreateFileMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidState,
|
|
"Node manager unavailable.")
|
|
};
|
|
}
|
|
if (string.IsNullOrEmpty(fileName))
|
|
{
|
|
return new CreateFileMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidArgument,
|
|
"File name required.")
|
|
};
|
|
}
|
|
string newPath = manager.CombineProviderPath(ProviderPath, fileName);
|
|
try
|
|
{
|
|
if (!requestFileOpen)
|
|
{
|
|
await manager.Provider.CreateFileAsync(newPath, cancellationToken).ConfigureAwait(false);
|
|
return new CreateFileMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Good,
|
|
FileNodeId = FileSystemNodeId.BuildFile(newPath, manager.NamespaceIndex),
|
|
FileHandle = 0u
|
|
};
|
|
}
|
|
|
|
NodeId fileNodeId = FileSystemNodeId.BuildFile(newPath, manager.NamespaceIndex);
|
|
FileHandle? handle = manager.GetOrCreateHandle(fileNodeId, newPath);
|
|
if (handle == null)
|
|
{
|
|
return new CreateFileMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidState,
|
|
"Failed to obtain file handle.")
|
|
};
|
|
}
|
|
// Open with write + erase to mirror the spec's
|
|
// "create + open for write" semantics.
|
|
ServiceResult openResult = handle.Open(0x6, out uint fileHandle);
|
|
return new CreateFileMethodStateResult
|
|
{
|
|
ServiceResult = openResult,
|
|
FileNodeId = fileNodeId,
|
|
FileHandle = fileHandle
|
|
};
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return new CreateFileMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied,
|
|
"Failed to create file.")
|
|
};
|
|
}
|
|
catch (IOException ex)
|
|
{
|
|
return new CreateFileMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(ex, StatusCodes.BadBrowseNameDuplicated,
|
|
"Directory or file with same name exists.")
|
|
};
|
|
}
|
|
}
|
|
|
|
private async ValueTask<DeleteFileMethodStateResult> OnDeleteFileSystemObjectAsync(
|
|
ISystemContext context, MethodState method, NodeId objectId,
|
|
NodeId objectToDelete, CancellationToken cancellationToken)
|
|
{
|
|
if (context?.SystemHandle is not FileSystemNodeManager manager)
|
|
{
|
|
return new DeleteFileMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidState,
|
|
"Node manager unavailable.")
|
|
};
|
|
}
|
|
if (!FileSystemNodeId.TryParse(objectToDelete, out FileSystemNodeId parsed))
|
|
{
|
|
return new DeleteFileMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidState,
|
|
"Not a file-system object.")
|
|
};
|
|
}
|
|
if (parsed.RootType == FileSystemNodeId.Root)
|
|
{
|
|
return new DeleteFileMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(StatusCodes.BadUserAccessDenied,
|
|
"Cannot delete the file-system root.")
|
|
};
|
|
}
|
|
try
|
|
{
|
|
await manager.Provider.DeleteAsync(parsed.ProviderPath, cancellationToken).ConfigureAwait(false);
|
|
manager.ForgetHandle(objectToDelete);
|
|
return new DeleteFileMethodStateResult { ServiceResult = ServiceResult.Good };
|
|
}
|
|
catch (FileNotFoundException ex)
|
|
{
|
|
return new DeleteFileMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotFound,
|
|
"File-system object not found.")
|
|
};
|
|
}
|
|
catch (DirectoryNotFoundException ex)
|
|
{
|
|
return new DeleteFileMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotFound,
|
|
"File-system object not found.")
|
|
};
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return new DeleteFileMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied,
|
|
"Failed to delete file-system object.")
|
|
};
|
|
}
|
|
catch (IOException ex)
|
|
{
|
|
return new DeleteFileMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied,
|
|
"Failed to delete file-system object.")
|
|
};
|
|
}
|
|
}
|
|
|
|
private async ValueTask<MoveOrCopyMethodStateResult> OnMoveOrCopyAsync(
|
|
ISystemContext context, MethodState method, NodeId objectId,
|
|
NodeId objectToMoveOrCopy, NodeId targetDirectory,
|
|
bool createCopy, string newName, CancellationToken cancellationToken)
|
|
{
|
|
if (context?.SystemHandle is not FileSystemNodeManager manager)
|
|
{
|
|
return new MoveOrCopyMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidState,
|
|
"Node manager unavailable.")
|
|
};
|
|
}
|
|
if (!FileSystemNodeId.TryParse(objectToMoveOrCopy, out FileSystemNodeId source) ||
|
|
source.RootType == FileSystemNodeId.Root)
|
|
{
|
|
return new MoveOrCopyMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidArgument,
|
|
"Source is not a directory or file.")
|
|
};
|
|
}
|
|
if (!FileSystemNodeId.TryParse(targetDirectory, out FileSystemNodeId target) ||
|
|
target.RootType == FileSystemNodeId.File)
|
|
{
|
|
return new MoveOrCopyMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(StatusCodes.BadInvalidArgument,
|
|
"Target is not a directory.")
|
|
};
|
|
}
|
|
|
|
string sourceName = ProviderPathName(source.ProviderPath);
|
|
string finalName = !string.IsNullOrEmpty(newName) ? newName : sourceName;
|
|
string targetPath = manager.CombineProviderPath(target.ProviderPath, finalName);
|
|
|
|
try
|
|
{
|
|
if (createCopy)
|
|
{
|
|
await manager.Provider.CopyAsync(source.ProviderPath, targetPath, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
await manager.Provider.MoveAsync(source.ProviderPath, targetPath, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
manager.ForgetHandle(objectToMoveOrCopy);
|
|
}
|
|
|
|
NodeId newNodeId = source.RootType == FileSystemNodeId.File
|
|
? FileSystemNodeId.BuildFile(targetPath, manager.NamespaceIndex)
|
|
: FileSystemNodeId.BuildDirectory(targetPath, manager.NamespaceIndex);
|
|
return new MoveOrCopyMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Good,
|
|
NewNodeId = newNodeId
|
|
};
|
|
}
|
|
catch (FileNotFoundException ex)
|
|
{
|
|
return new MoveOrCopyMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotFound,
|
|
"Source not found.")
|
|
};
|
|
}
|
|
catch (DirectoryNotFoundException ex)
|
|
{
|
|
return new MoveOrCopyMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(ex, StatusCodes.BadNotFound,
|
|
"Source not found.")
|
|
};
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return new MoveOrCopyMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied,
|
|
"Failed to move or copy.")
|
|
};
|
|
}
|
|
catch (IOException ex)
|
|
{
|
|
return new MoveOrCopyMethodStateResult
|
|
{
|
|
ServiceResult = ServiceResult.Create(ex, StatusCodes.BadBrowseNameDuplicated,
|
|
"Failed to move or copy.")
|
|
};
|
|
}
|
|
}
|
|
|
|
private static string ProviderPathName(string providerPath)
|
|
{
|
|
if (string.IsNullOrEmpty(providerPath))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
int slash = providerPath.LastIndexOf('/');
|
|
return slash < 0 ? providerPath : providerPath[(slash + 1)..];
|
|
}
|
|
}
|
|
}
|