/* ========================================================================
* 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.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Server.FileSystem;
using Opc.Ua.Server.Tests.NodeManager;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests.FileSystem
{
///
/// Deterministic offline tests for and
/// using a mocked server and a
/// physical provider rooted at a temp directory.
///
[TestFixture]
[Category("FileSystem")]
public class FileSystemNodeManagerTests
{
private string m_root = null!;
private ITelemetryContext m_telemetry = null!;
[SetUp]
public void SetUp()
{
m_root = Path.Combine(Path.GetTempPath(), "fsnm-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(m_root);
m_telemetry = NUnitTelemetryContext.Create();
}
[TearDown]
public void TearDown()
{
try
{
if (Directory.Exists(m_root))
{
Directory.Delete(m_root, recursive: true);
}
}
catch (IOException)
{
// best-effort
}
}
private FileSystemNodeManager CreateManager(out PhysicalFileSystemProvider provider)
{
Mock mockServer = DeterministicServerMock.Create(out _);
mockServer.Setup(s => s.Telemetry).Returns(m_telemetry);
provider = new PhysicalFileSystemProvider(m_root, "TestMount");
var config = new ApplicationConfiguration
{
ServerConfiguration = new ServerConfiguration
{
MaxNotificationQueueSize = 100,
MaxDurableNotificationQueueSize = 200
}
};
return new FileSystemNodeManager(mockServer.Object, config, provider);
}
[Test]
public void ConstructorWithNullProviderThrows()
{
Mock mockServer = DeterministicServerMock.Create(out _);
mockServer.Setup(s => s.Telemetry).Returns(m_telemetry);
Assert.That(
() => new FileSystemNodeManager(mockServer.Object, new ApplicationConfiguration(), null!),
Throws.TypeOf());
}
[Test]
public void ConstructorWithEmptyMountNameThrows()
{
Mock mockServer = DeterministicServerMock.Create(out _);
mockServer.Setup(s => s.Telemetry).Returns(m_telemetry);
var provider = new EmptyMountProvider();
Assert.That(
() => new FileSystemNodeManager(mockServer.Object, new ApplicationConfiguration(), provider),
Throws.TypeOf());
}
[Test]
public void ProviderAndNamespaceIndexAreExposed()
{
using FileSystemNodeManager manager = CreateManager(out PhysicalFileSystemProvider provider);
Assert.That(manager.Provider, Is.SameAs(provider));
Assert.That(manager.NamespaceIndex, Is.GreaterThan((ushort)0));
}
[Test]
public void NewReturnsNullForNullNode()
{
using FileSystemNodeManager manager = CreateManager(out _);
NodeId result = manager.New(manager.SystemContext, null!);
Assert.That(result, Is.EqualTo(NodeId.Null));
}
[Test]
public async Task CreateAddressSpaceAddsRootReferenceAsync()
{
using FileSystemNodeManager manager = CreateManager(out _);
var externalReferences = new Dictionary>();
await manager.CreateAddressSpaceAsync(externalReferences, CancellationToken.None).ConfigureAwait(false);
Assert.That(externalReferences.ContainsKey(ObjectIds.FileSystem), Is.True);
Assert.That(externalReferences[ObjectIds.FileSystem], Has.Count.EqualTo(1));
Assert.That(
externalReferences[ObjectIds.FileSystem][0].ReferenceTypeId,
Is.EqualTo(ReferenceTypeIds.HasComponent));
}
[Test]
public void GetParentNodeIdReturnsNullForRootPath()
{
using FileSystemNodeManager manager = CreateManager(out _);
Assert.That(manager.GetParentNodeId(string.Empty), Is.EqualTo(NodeId.Null));
}
[Test]
public void GetParentNodeIdReturnsRootForTopLevelEntry()
{
using FileSystemNodeManager manager = CreateManager(out _);
NodeId parent = manager.GetParentNodeId("file.txt");
Assert.That(FileSystemNodeId.TryParse(parent, out FileSystemNodeId parsed), Is.True);
Assert.That(parsed.RootType, Is.EqualTo(FileSystemNodeId.Root));
}
[Test]
public void GetParentNodeIdReturnsDirectoryForNestedEntry()
{
using FileSystemNodeManager manager = CreateManager(out _);
NodeId parent = manager.GetParentNodeId("folder/sub/file.txt");
Assert.That(FileSystemNodeId.TryParse(parent, out FileSystemNodeId parsed), Is.True);
Assert.That(parsed.RootType, Is.EqualTo(FileSystemNodeId.Directory));
Assert.That(parsed.ProviderPath, Is.EqualTo("folder/sub"));
}
[Test]
public void CombineProviderPathHandlesEmptyParent()
{
using FileSystemNodeManager manager = CreateManager(out _);
Assert.That(manager.CombineProviderPath(string.Empty, "a"), Is.EqualTo("a"));
}
[Test]
public void CombineProviderPathJoinsWithSlash()
{
using FileSystemNodeManager manager = CreateManager(out _);
Assert.That(manager.CombineProviderPath("folder", "b"), Is.EqualTo("folder/b"));
Assert.That(manager.CombineProviderPath("folder/", "b"), Is.EqualTo("folder/b"));
}
[TestCase("run-2024-05-01T10:00:00.csv")]
[TestCase("12:00/part.txt")]
[TestCase(@"folder\name.txt")]
[TestCase(@"\")]
public void UnixColonAndBackslashNamesRemainAddressable(string path)
{
Assert.That(FileSystemNodeManager.TryNormalizeProviderPath(path, windowsPaths: false, out string actual),
Is.True);
Assert.That(actual, Is.EqualTo(path));
}
[TestCase("run-2024-05-01T10:00:00.csv")]
[TestCase("12:00/part.txt")]
[TestCase(@"folder\name.txt")]
public void WindowsPathSyntaxCannotBecomeAProviderName(string path)
{
Assert.That(FileSystemNodeManager.TryNormalizeProviderPath(path, windowsPaths: true, out _), Is.False);
}
[Test]
public void GetOrCreateHandleReturnsSameHandleForSameNodeId()
{
using FileSystemNodeManager manager = CreateManager(out _);
NodeId nodeId = FileSystemNodeId.BuildFile("f.txt", manager.NamespaceIndex);
FileHandle? first = manager.GetOrCreateHandle(nodeId, "f.txt");
FileHandle? second = manager.GetOrCreateHandle(nodeId, "f.txt");
Assert.That(first, Is.Not.Null);
Assert.That(second, Is.SameAs(first));
}
[Test]
public void ForgetHandleRemovesHandle()
{
using FileSystemNodeManager manager = CreateManager(out _);
NodeId nodeId = FileSystemNodeId.BuildFile("f.txt", manager.NamespaceIndex);
FileHandle? first = manager.GetOrCreateHandle(nodeId, "f.txt");
manager.ForgetHandle(nodeId);
FileHandle? afterForget = manager.GetOrCreateHandle(nodeId, "f.txt");
Assert.That(afterForget, Is.Not.SameAs(first));
}
[Test]
public void ForgetHandleForUnknownNodeIdIsNoOp()
{
using FileSystemNodeManager manager = CreateManager(out _);
Assert.DoesNotThrow(
() => manager.ForgetHandle(FileSystemNodeId.BuildFile("unknown", manager.NamespaceIndex)));
}
[Test]
public async Task GetManagerHandleAsyncReturnsEmptyForNodesOutsideNamespaceAsync()
{
using FileSystemNodeManager manager = CreateManager(out _);
NodeHandle? handle = await GetManagerHandleAsync(manager, new NodeId("1:file.txt", 999))
.ConfigureAwait(false);
Assert.That(handle, Is.Null);
}
[Test]
public async Task GetManagerHandleAsyncReturnsParsedHandleForFileSystemNodeAsync()
{
using FileSystemNodeManager manager = CreateManager(out _);
NodeId fileId = FileSystemNodeId.BuildFile("file.txt", manager.NamespaceIndex);
NodeHandle? handle = await GetManagerHandleAsync(manager, fileId)
.ConfigureAwait(false);
Assert.That(handle, Is.Not.Null);
Assert.That(handle!.NodeId, Is.EqualTo(fileId));
Assert.That(handle.Validated, Is.False);
Assert.That(handle.ParsedNodeId, Is.Not.Null);
}
[Test]
public async Task GetManagerHandleAsyncReturnsEmptyForMalformedFileSystemNodeAsync()
{
using FileSystemNodeManager manager = CreateManager(out _);
NodeHandle? handle = await GetManagerHandleAsync(manager, new NodeId("not-a-file-system-id", manager.NamespaceIndex))
.ConfigureAwait(false);
Assert.That(handle, Is.Null);
}
[Test]
public async Task ValidateNodeAsyncCreatesDirectoryAndFileStatesAsync()
{
Directory.CreateDirectory(Path.Combine(m_root, "folder"));
File.WriteAllText(Path.Combine(m_root, "file.txt"), "content");
using FileSystemNodeManager manager = CreateManager(out _);
NodeHandle? rootHandle = await GetManagerHandleAsync(
manager,
FileSystemNodeId.BuildRoot(manager.NamespaceIndex))
.ConfigureAwait(false);
NodeHandle? directoryHandle = await GetManagerHandleAsync(
manager,
FileSystemNodeId.BuildDirectory("folder", manager.NamespaceIndex))
.ConfigureAwait(false);
NodeHandle? fileHandle = await GetManagerHandleAsync(
manager,
FileSystemNodeId.BuildFile("file.txt", manager.NamespaceIndex))
.ConfigureAwait(false);
NodeState? root = await ValidateNodeAsync(manager, rootHandle!, new Dictionary())
.ConfigureAwait(false);
NodeState? directory = await ValidateNodeAsync(
manager,
directoryHandle!,
new Dictionary())
.ConfigureAwait(false);
NodeState? file = await ValidateNodeAsync(manager, fileHandle!, new Dictionary())
.ConfigureAwait(false);
Assert.That(root, Is.TypeOf());
Assert.That(directory, Is.TypeOf());
Assert.That(file, Is.TypeOf());
Assert.That(rootHandle!.Validated, Is.True);
Assert.That(directoryHandle!.Validated, Is.True);
Assert.That(fileHandle!.Validated, Is.True);
}
[Test]
public async Task ValidateNodeAsyncReturnsCachedNodeAndNullCacheEntriesAsync()
{
using FileSystemNodeManager manager = CreateManager(out _);
NodeId fileId = FileSystemNodeId.BuildFile("file.txt", manager.NamespaceIndex);
NodeHandle? cachedHandle = await GetManagerHandleAsync(manager, fileId)
.ConfigureAwait(false);
var cachedNode = new BaseDataVariableState(parent: null)
{
NodeId = fileId,
BrowseName = new QualifiedName("Cached", manager.NamespaceIndex)
};
var cache = new Dictionary { [fileId] = cachedNode };
NodeState? resolved = await ValidateNodeAsync(manager, cachedHandle!, cache)
.ConfigureAwait(false);
NodeHandle? nullHandle = await GetManagerHandleAsync(manager, fileId)
.ConfigureAwait(false);
cache[fileId] = null!;
NodeState? nullResolved = await ValidateNodeAsync(manager, nullHandle!, cache)
.ConfigureAwait(false);
Assert.That(resolved, Is.SameAs(cachedNode));
Assert.That(cachedHandle!.Validated, Is.True);
Assert.That(nullResolved, Is.Null);
}
[Test]
public async Task ValidateNodeAsyncReturnsNullForMissingEntryAndMissingComponentAsync()
{
File.WriteAllText(Path.Combine(m_root, "file.txt"), "content");
using FileSystemNodeManager manager = CreateManager(out _);
NodeHandle? missingEntryHandle = await GetManagerHandleAsync(
manager,
FileSystemNodeId.BuildFile("missing.txt", manager.NamespaceIndex))
.ConfigureAwait(false);
var missingComponentId = new FileSystemNodeId(
FileSystemNodeId.File,
"file.txt",
manager.NamespaceIndex,
"MissingComponent").ToNodeId();
NodeHandle? missingComponentHandle = await GetManagerHandleAsync(manager, missingComponentId)
.ConfigureAwait(false);
NodeState? missingEntry = await ValidateNodeAsync(
manager,
missingEntryHandle!,
new Dictionary())
.ConfigureAwait(false);
NodeState? missingComponent = await ValidateNodeAsync(
manager,
missingComponentHandle!,
new Dictionary())
.ConfigureAwait(false);
Assert.That(missingEntry, Is.Null);
Assert.That(missingComponent, Is.Null);
}
[Test]
public async Task DeleteAddressSpaceAsyncDisposesAndClearsHandlesAsync()
{
using FileSystemNodeManager manager = CreateManager(out _);
NodeId nodeId = FileSystemNodeId.BuildFile("f.txt", manager.NamespaceIndex);
FileHandle? first = manager.GetOrCreateHandle(nodeId, "f.txt");
await manager.DeleteAddressSpaceAsync(CancellationToken.None)
.ConfigureAwait(false);
FileHandle? afterDelete = manager.GetOrCreateHandle(nodeId, "f.txt");
Assert.That(afterDelete, Is.Not.SameAs(first));
}
[Test]
public void DisposeClosesOpenFileHandles()
{
FileSystemNodeManager manager = CreateManager(out _);
NodeId nodeId = FileSystemNodeId.BuildFile("f.txt", manager.NamespaceIndex);
FileHandle handle = manager.GetOrCreateHandle(nodeId, "f.txt")!;
var sessionId = new NodeId("dispose-session", 0);
ServiceResult openResult = handle.Open(sessionId, 0x2, out uint fileHandle);
Assert.That(ServiceResult.IsGood(openResult), Is.True);
manager.Dispose();
Assert.That(handle.OpenCount, Is.Zero);
Assert.That(handle.GetStream(sessionId, fileHandle), Is.Null);
}
[Test]
public async Task SessionClosingAsyncClosesHandlesOwnedBySessionAcrossMultipleFilesAsync()
{
using FileSystemNodeManager manager = CreateManager(out _);
NodeId nodeIdA = FileSystemNodeId.BuildFile("a.txt", manager.NamespaceIndex);
NodeId nodeIdB = FileSystemNodeId.BuildFile("b.txt", manager.NamespaceIndex);
FileHandle handleA = manager.GetOrCreateHandle(nodeIdA, "a.txt")!;
FileHandle handleB = manager.GetOrCreateHandle(nodeIdB, "b.txt")!;
var sessionId = new NodeId("closing-session", 0);
var otherSessionId = new NodeId("other-closing-session", 0);
handleA.Open(sessionId, 0x2, out uint fileHandleA);
handleB.Open(otherSessionId, 0x2, out uint fileHandleB);
var session = new Mock();
session.Setup(s => s.Id).Returns(sessionId);
session.Setup(s => s.Identity).Returns(Mock.Of(i => i.TokenType == UserTokenType.UserName));
session.Setup(s => s.PreferredLocales).Returns([]);
var operationContext = new OperationContext(session.Object, DiagnosticsMasks.None);
await manager.SessionClosingAsync(
operationContext,
sessionId,
deleteSubscriptions: false).ConfigureAwait(false);
Assert.That(handleA.OpenCount, Is.Zero);
Assert.That(handleA.GetStream(sessionId, fileHandleA), Is.Null);
Assert.That(handleB.OpenCount, Is.EqualTo(1));
Assert.That(handleB.GetStream(otherSessionId, fileHandleB), Is.Not.Null);
}
[Test]
public void FactoryWithNullProviderThrows()
{
Assert.That(
() => new FileSystemNodeManagerFactory(null!),
Throws.TypeOf());
}
[Test]
public void FactoryExposesNamespaceUri()
{
var provider = new PhysicalFileSystemProvider(m_root, "MyMount");
var factory = new FileSystemNodeManagerFactory(provider);
ArrayOf namespaces = factory.NamespacesUris;
Assert.That(namespaces, Has.Count.EqualTo(1));
Assert.That(
namespaces[0],
Is.EqualTo(FileSystemNodeManager.NamespaceUriBase + "/MyMount"));
}
[Test]
public void FactoryCreateReturnsSyncNodeManager()
{
Mock mockServer = DeterministicServerMock.Create(out _);
mockServer.Setup(s => s.Telemetry).Returns(m_telemetry);
var provider = new PhysicalFileSystemProvider(m_root, "MyMount");
var factory = new FileSystemNodeManagerFactory(provider);
INodeManager nodeManager = factory.Create(mockServer.Object, new ApplicationConfiguration());
Assert.That(nodeManager, Is.Not.Null);
}
[Test]
public async Task FactoryCreateAsyncReturnsAsyncNodeManagerAsync()
{
Mock mockServer = DeterministicServerMock.Create(out _);
mockServer.Setup(s => s.Telemetry).Returns(m_telemetry);
var provider = new PhysicalFileSystemProvider(m_root, "MyMount");
var factory = new FileSystemNodeManagerFactory(provider);
IAsyncNodeManager nodeManager = await factory.CreateAsync(
mockServer.Object, new ApplicationConfiguration()).ConfigureAwait(false);
Assert.That(nodeManager, Is.Not.Null);
}
private static async Task GetManagerHandleAsync(
FileSystemNodeManager manager,
NodeId nodeId)
{
MethodInfo method = typeof(FileSystemNodeManager).GetMethod(
"GetManagerHandleAsync",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var cache = new Dictionary();
var result = (ValueTask)method.Invoke(
manager,
[manager.SystemContext, nodeId, cache, CancellationToken.None])!;
return await result.ConfigureAwait(false);
}
private static async Task ValidateNodeAsync(
FileSystemNodeManager manager,
NodeHandle handle,
IDictionary cache)
{
MethodInfo method = typeof(FileSystemNodeManager).GetMethod(
"ValidateNodeAsync",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var result = (ValueTask)method.Invoke(
manager,
[manager.SystemContext, handle, cache, CancellationToken.None])!;
return await result.ConfigureAwait(false);
}
///
/// A provider whose MountName is empty, used to drive the
/// namespace-URI validation guard in the node manager.
///
private sealed class EmptyMountProvider : IFileSystemProvider
{
public string MountName => string.Empty;
public bool IsWritable => false;
public ValueTask GetEntryAsync(string path, CancellationToken ct)
{
return new ValueTask((FileSystemEntry?)null);
}
public async IAsyncEnumerable EnumerateAsync(
string path,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
public ValueTask OpenReadAsync(string path, CancellationToken ct)
{
throw new NotSupportedException();
}
public ValueTask OpenWriteAsync(string path, FileWriteMode mode, CancellationToken ct)
{
throw new NotSupportedException();
}
public ValueTask CreateDirectoryAsync(string path, CancellationToken ct)
{
throw new NotSupportedException();
}
public ValueTask CreateFileAsync(string path, CancellationToken ct)
{
throw new NotSupportedException();
}
public ValueTask DeleteAsync(string path, CancellationToken ct)
{
throw new NotSupportedException();
}
public ValueTask MoveAsync(string source, string target, CancellationToken ct)
{
throw new NotSupportedException();
}
public ValueTask CopyAsync(string source, string target, CancellationToken ct)
{
throw new NotSupportedException();
}
}
}
}