/* ======================================================================== * 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.IO; 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 { /// /// Verifies mount-root protection and canonical path validation before file-system provider mutations. /// [TestFixture] [Category("FileSystem")] public sealed class FileSystemMountRegressionTests { /// /// Verifies that alternative root identifiers cannot delete, move, or copy the mounted root. /// [Test] public async Task AlternateRootIdsCannotDeleteMoveOrCopyMountAsync( [Values("delete", "move", "copy")] string operation, [ValueSource(nameof(s_rootIds))] string identifier) { Mock provider = CreateProvider(); using FileSystemNodeManager manager = CreateManager(provider.Object); DirectoryObjectState root = CreateRoot(manager); var source = new NodeId(identifier, manager.NamespaceIndex); ServiceResult result = await MutateAsync(root, manager.SystemContext, source, operation) .ConfigureAwait(false); Assert.Multiple(() => { Assert.That(result.StatusCode.Code, Is.EqualTo(operation == "delete" ? StatusCodes.BadUserAccessDenied : StatusCodes.BadInvalidArgument)); VerifyNoMutation(provider); }); } /// /// Verifies that noncanonical source paths are rejected before reaching any provider mutation. /// [Test] public async Task NonCanonicalObjectPathsCannotDeleteMoveOrCopyAsync( [Values("delete", "move", "copy")] string operation, [ValueSource(nameof(s_nonCanonicalProviderPaths))] string providerPath, [Values] bool isDirectory) { Mock provider = CreateProvider(); using FileSystemNodeManager manager = CreateManager(provider.Object); DirectoryObjectState root = CreateRoot(manager); NodeId source = isDirectory ? FileSystemNodeId.BuildDirectory(providerPath, manager.NamespaceIndex) : FileSystemNodeId.BuildFile(providerPath, manager.NamespaceIndex); ServiceResult result = await MutateAsync(root, manager.SystemContext, source, operation) .ConfigureAwait(false); StatusCode expectedStatus = operation != "delete" ? StatusCodes.BadInvalidArgument : providerPath is "/" or "\\" or "//" or "\\\\" or "/\\/" ? StatusCodes.BadUserAccessDenied : StatusCodes.BadNotFound; Assert.Multiple(() => { Assert.That(result.StatusCode, Is.EqualTo(expectedStatus)); VerifyNoMutation(provider); }); } /// /// Verifies that noncanonical destination paths reject moves and copies without returning a new node. /// [Test] public async Task NonCanonicalMoveOrCopyTargetsDoNotInvokeProviderAsync( [Values] bool createCopy, [ValueSource(nameof(s_invalidNonRootPaths))] string providerPath) { Mock provider = CreateProvider(); using FileSystemNodeManager manager = CreateManager(provider.Object); DirectoryObjectState root = CreateRoot(manager); NodeId source = FileSystemNodeId.BuildFile("keep.txt", manager.NamespaceIndex); NodeId target = FileSystemNodeId.BuildDirectory(providerPath, manager.NamespaceIndex); MoveOrCopyMethodStateResult result = await root.MoveOrCopy!.OnCallAsync!( manager.SystemContext, root.MoveOrCopy, root.NodeId, source, target, createCopy, "renamed.txt", CancellationToken.None).ConfigureAwait(false); Assert.Multiple(() => { Assert.That(result.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); Assert.That(result.NewNodeId.IsNull, Is.True); VerifyNoMutation(provider); }); } /// /// Verifies that valid nested paths, including unusual legal names, reach the provider unchanged. /// [Test] public async Task OrdinaryNestedProviderPathsRemainUnchangedAsync( [Values("delete", "move", "copy")] string operation, [Values("sub/keep.txt", "sub/.hidden", "sub/name..txt", "sub/.../file", "data dir/nested file.txt", "sub/a&b?c.txt")] string providerPath, [Values] bool isDirectory) { Mock provider = CreateProvider(); using FileSystemNodeManager manager = CreateManager(provider.Object); // Delete and MoveOrCopy are called on the directory that organizes the source. string parentPath = providerPath[..providerPath.LastIndexOf('/')]; var parent = new DirectoryObjectState(manager.SystemContext, FileSystemNodeId.BuildDirectory(parentPath, manager.NamespaceIndex), parentPath, parentPath, isRoot: false); NodeId source = isDirectory ? FileSystemNodeId.BuildDirectory(providerPath, manager.NamespaceIndex) : FileSystemNodeId.BuildFile(providerPath, manager.NamespaceIndex); const string TargetPath = "destination/nested/renamed.txt"; if (operation == "delete") { ServiceResult result = await MutateAsync(parent, manager.SystemContext, source, operation) .ConfigureAwait(false); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good)); } else { NodeId target = FileSystemNodeId.BuildDirectory("destination/nested", manager.NamespaceIndex); MoveOrCopyMethodStateResult result = await parent.MoveOrCopy!.OnCallAsync!( manager.SystemContext, parent.MoveOrCopy, parent.NodeId, source, target, operation == "copy", "renamed.txt", CancellationToken.None).ConfigureAwait(false); NodeId expectedId = isDirectory ? FileSystemNodeId.BuildDirectory(TargetPath, manager.NamespaceIndex) : FileSystemNodeId.BuildFile(TargetPath, manager.NamespaceIndex); Assert.That(result.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That(result.NewNodeId, Is.EqualTo(expectedId)); } VerifySingleMutation(provider, operation, providerPath, TargetPath); } /// /// Verifies that empty or separator-only destinations resolve to the mount root without a path prefix. /// [Test] public async Task SeparatorOnlyTargetsResolveToCanonicalMountRootAsync( [Values] bool createCopy, [ValueSource(nameof(s_rootPaths))] string providerPath, [Values(FileSystemNodeId.Root, FileSystemNodeId.Directory)] int rootType) { Mock provider = CreateProvider(); using FileSystemNodeManager manager = CreateManager(provider.Object); DirectoryObjectState root = CreateRoot(manager); NodeId source = FileSystemNodeId.BuildFile("keep.txt", manager.NamespaceIndex); var target = new FileSystemNodeId(rootType, providerPath, manager.NamespaceIndex).ToNodeId(); MoveOrCopyMethodStateResult result = await root.MoveOrCopy!.OnCallAsync!( manager.SystemContext, root.MoveOrCopy, root.NodeId, source, target, createCopy, "renamed.txt", CancellationToken.None).ConfigureAwait(false); Assert.That(result.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That(result.NewNodeId, Is.EqualTo(FileSystemNodeId.BuildFile("renamed.txt", manager.NamespaceIndex))); VerifySingleMutation(provider, createCopy ? "copy" : "move", "keep.txt", "renamed.txt"); } /// /// Verifies that invalid type prefixes, method identifiers, and foreign namespaces cannot mutate provider /// entries. /// [Test] public async Task InvalidObjectIdsCannotDeleteMoveOrCopyAsync( [Values("delete", "move", "copy")] string operation, [Values("3:keep.txt", "4294967298:keep.txt", "2147483648:keep.txt", "0:keep.txt", "2:keep.txt?Open", "2:keep.txt?")] string identifier, [Values] bool foreignNamespace) { Mock provider = CreateProvider(); using FileSystemNodeManager manager = CreateManager(provider.Object); DirectoryObjectState root = CreateRoot(manager); var source = new NodeId(identifier, foreignNamespace ? (ushort)(manager.NamespaceIndex + 1) : manager.NamespaceIndex); ServiceResult result = await MutateAsync(root, manager.SystemContext, source, operation) .ConfigureAwait(false); Assert.Multiple(() => { Assert.That(result.StatusCode.Code, Is.EqualTo(operation == "delete" ? StatusCodes.BadNotFound : StatusCodes.BadInvalidArgument)); VerifyNoMutation(provider); }); } /// /// Verifies that a valid-looking path in another namespace cannot alias a mounted file. /// [Test] public async Task ForeignNamespaceCannotAliasAValidFileAsync( [Values("delete", "move", "copy")] string operation) { Mock provider = CreateProvider(); using FileSystemNodeManager manager = CreateManager(provider.Object); DirectoryObjectState root = CreateRoot(manager); NodeId source = FileSystemNodeId.BuildFile("keep.txt", (ushort)(manager.NamespaceIndex + 1)); ServiceResult result = await MutateAsync(root, manager.SystemContext, source, operation) .ConfigureAwait(false); Assert.Multiple(() => { Assert.That(ServiceResult.IsBad(result), Is.True); VerifyNoMutation(provider); }); } /// /// Verifies that invalid destination node kinds or namespaces reject moves and copies before provider access. /// [Test] public async Task InvalidMoveOrCopyTargetDoesNotInvokeProviderAsync( [Values] bool createCopy, [Values("3:target", "2:target", "1:target?CreateFile", "0:target")] string identifier, [Values] bool foreignNamespace) { Mock provider = CreateProvider(); using FileSystemNodeManager manager = CreateManager(provider.Object); DirectoryObjectState root = CreateRoot(manager); NodeId source = FileSystemNodeId.BuildFile("keep.txt", manager.NamespaceIndex); var target = new NodeId(identifier, foreignNamespace ? (ushort)(manager.NamespaceIndex + 1) : manager.NamespaceIndex); MoveOrCopyMethodStateResult result = await root.MoveOrCopy!.OnCallAsync!( manager.SystemContext, root.MoveOrCopy, root.NodeId, source, target, createCopy, "renamed.txt", CancellationToken.None).ConfigureAwait(false); Assert.Multiple(() => { Assert.That(result.ServiceResult.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); VerifyNoMutation(provider); }); } /// /// Verifies that parsing rejects unsupported node kinds and numeric prefixes that overflow the type range. /// [TestCase("3:")] [TestCase("2147483648:")] [TestCase("4294967296:")] [TestCase("4294967297:")] [TestCase("4294967298:")] [TestCase("999999999999999999999999999999999999:")] public void ParserRejectsUnsupportedAndOverflowingTypes(string identifier) { Assert.That(FileSystemNodeId.TryParse(new NodeId(identifier, 2), out _), Is.False); } /// /// Verifies that physical-provider root aliases are rejected while existing files and directories remain /// intact. /// [Test] public async Task PhysicalProviderRejectsRootMutationsBeforeTouchingContentsAsync( [Values("delete", "move", "copy")] string operation, [Values("", "/", ".", "child/..")] string source) { string root = Path.Combine(Path.GetTempPath(), "fs-root-regression-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(root, "child")); string sentinel = Path.Combine(root, "keep.txt"); await WriteTextAsync(sentinel, "unchanged").ConfigureAwait(false); var provider = new PhysicalFileSystemProvider(root, "RootRegression"); try { // An existing target also keeps the unfixed recursive copy bounded. Assert.ThrowsAsync(async () => { switch (operation) { case "delete": await provider.DeleteAsync(source, CancellationToken.None).ConfigureAwait(false); break; case "move": await provider.MoveAsync(source, "keep.txt", CancellationToken.None).ConfigureAwait(false); break; default: await provider.CopyAsync(source, "keep.txt", CancellationToken.None).ConfigureAwait(false); break; } }); Assert.That(await ReadTextAsync(sentinel).ConfigureAwait(false), Is.EqualTo("unchanged")); Assert.That(Directory.Exists(Path.Combine(root, "child")), Is.True); } finally { if (Directory.Exists(root)) { Directory.Delete(root, recursive: true); } } } /// /// Verifies that protecting the mount root still permits deleting, moving, and copying ordinary child files. /// [Test] public async Task OrdinaryChildMutationsRemainSupportedAsync( [Values("delete", "move", "copy")] string operation) { string rootPath = Path.Combine(Path.GetTempPath(), "fs-child-regression-" + Guid.NewGuid().ToString("N")); var provider = new PhysicalFileSystemProvider(rootPath, "ChildRegression"); try { await WriteTextAsync(Path.Combine(rootPath, "keep.txt"), "payload").ConfigureAwait(false); using FileSystemNodeManager manager = CreateManager(provider); DirectoryObjectState root = CreateRoot(manager); NodeId source = FileSystemNodeId.BuildFile("keep.txt", manager.NamespaceIndex); ServiceResult result = await MutateAsync(root, manager.SystemContext, source, operation) .ConfigureAwait(false); Assert.That(ServiceResult.IsGood(result), Is.True); Assert.That(File.Exists(Path.Combine(rootPath, "keep.txt")), Is.EqualTo(operation == "copy")); if (operation != "delete") { Assert.That(await ReadTextAsync(Path.Combine(rootPath, "renamed.txt")) .ConfigureAwait(false), Is.EqualTo("payload")); } } finally { Directory.Delete(rootPath, recursive: true); } } /// /// Writes a sentinel payload used to detect unintended provider mutations. /// private static async Task WriteTextAsync(string path, string value) { using var writer = new StreamWriter(path); await writer.WriteAsync(value).ConfigureAwait(false); } /// /// Reads a sentinel or copied payload to verify preserved file contents. /// private static async Task ReadTextAsync(string path) { using var reader = new StreamReader(path); return await reader.ReadToEndAsync().ConfigureAwait(false); } /// /// Creates a writable provider mock whose mutation calls can be checked for admission and exact paths. /// private static Mock CreateProvider() { var provider = new Mock(); provider.SetupGet(p => p.MountName).Returns("MountRegression"); provider.SetupGet(p => p.IsWritable).Returns(true); provider.Setup(p => p.DeleteAsync(It.IsAny(), It.IsAny())) .Returns(default(ValueTask)); provider.Setup(p => p.MoveAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(default(ValueTask)); provider.Setup(p => p.CopyAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(default(ValueTask)); return provider; } /// /// Creates a file-system manager over the supplied provider and deterministic server mock. /// private static FileSystemNodeManager CreateManager(IFileSystemProvider provider) { Mock server = DeterministicServerMock.Create(out _); server.SetupGet(s => s.Telemetry).Returns(NUnitTelemetryContext.Create()); return new FileSystemNodeManager(server.Object, new ApplicationConfiguration(), provider); } /// /// Creates the mounted root node exposing file-system mutation methods. /// private static DirectoryObjectState CreateRoot(FileSystemNodeManager manager) { return new DirectoryObjectState(manager.SystemContext, FileSystemNodeId.BuildRoot(manager.NamespaceIndex), string.Empty, "Root", isRoot: true); } /// /// Invokes deletion or a move or copy into the root through the mounted directory methods. /// private static async ValueTask MutateAsync( DirectoryObjectState root, ISystemContext context, NodeId source, string operation) { if (operation == "delete") { DeleteFileMethodStateResult deleted = await root.DeleteFileSystemObject!.OnCallAsync!( context, root.DeleteFileSystemObject, root.NodeId, source, CancellationToken.None) .ConfigureAwait(false); return deleted.ServiceResult; } MoveOrCopyMethodStateResult moved = await root.MoveOrCopy!.OnCallAsync!( context, root.MoveOrCopy, root.NodeId, source, root.NodeId, operation == "copy", "renamed.txt", CancellationToken.None).ConfigureAwait(false); return moved.ServiceResult; } /// /// Verifies that no delete, move, or copy request reached the provider. /// private static void VerifyNoMutation(Mock provider) { provider.Verify(p => p.DeleteAsync(It.IsAny(), It.IsAny()), Times.Never); provider.Verify(p => p.MoveAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); provider.Verify(p => p.CopyAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } /// /// Verifies that only the selected mutation occurred once with the expected source and destination paths. /// private static void VerifySingleMutation( Mock provider, string operation, string source, string target) { provider.Verify(p => p.DeleteAsync(It.IsAny(), It.IsAny()), Times.Exactly(operation == "delete" ? 1 : 0)); provider.Verify(p => p.MoveAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(operation == "move" ? 1 : 0)); provider.Verify(p => p.CopyAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(operation == "copy" ? 1 : 0)); provider.Verify(p => p.DeleteAsync(source, It.IsAny()), Times.Exactly(operation == "delete" ? 1 : 0)); provider.Verify(p => p.MoveAsync(source, target, It.IsAny()), Times.Exactly(operation == "move" ? 1 : 0)); provider.Verify(p => p.CopyAsync(source, target, It.IsAny()), Times.Exactly(operation == "copy" ? 1 : 0)); } /// /// Supplies nonroot paths containing traversal, noncanonical separators, or invalid path components. /// private static readonly string[] s_invalidNonRootPaths = [ ".", "..", "sub/..", "sub/../", "../sub", "sub/../leaf", "sub//leaf", "sub/./leaf", "/sub", "sub/", " ", "sub/ /leaf", .. Path.DirectorySeparatorChar == '\\' ? [ "sub\\..", "sub\\..\\", "sub\\..\\leaf", "sub\\\\leaf", "sub\\.\\leaf", "\\sub", "sub\\leaf", "C:/sub", "C:\\sub", "sub/stream:name" ] : Array.Empty() ]; /// /// Combines root aliases and invalid nonroot paths for source-path admission tests. /// private static readonly string[] s_nonCanonicalProviderPaths = [ "/", "//", .. s_invalidNonRootPaths, .. Path.DirectorySeparatorChar == '\\' ? ["\\", "\\\\", "/\\/"] : Array.Empty() ]; private static readonly string[] s_rootIds = Path.DirectorySeparatorChar == '\\' ? ["0:", "1:", "2:", "0:/", "0:\\", "0://"] : ["0:", "1:", "2:", "0:/", "0://"]; private static readonly string[] s_rootPaths = Path.DirectorySeparatorChar == '\\' ? [string.Empty, "/", "\\", "//", "\\\\", "/\\/"] : [string.Empty, "/", "//"]; } }