/* ======================================================================== * 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.Text; 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 that drive /// the FileType method handlers (Open/Read/Write/Close/Get/SetPosition), the /// metadata property read hooks and the browser population against a physical /// provider rooted at a temp directory. /// [TestFixture] [Category("FileSystem")] public class FileObjectStateTests { private string m_root = null!; private ITelemetryContext m_telemetry = null!; private FileSystemNodeManager m_manager = null!; private ISystemContext m_context = null!; private Mock m_session = null!; private NodeId m_sessionId; [SetUp] public void SetUp() { m_root = Path.Combine(Path.GetTempPath(), "fos-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(m_root); m_telemetry = NUnitTelemetryContext.Create(); Mock mockServer = DeterministicServerMock.Create(out _); mockServer.Setup(s => s.Telemetry).Returns(m_telemetry); var provider = new PhysicalFileSystemProvider(m_root, "TestMount"); m_manager = new FileSystemNodeManager(mockServer.Object, new ApplicationConfiguration(), provider); m_sessionId = new NodeId("file-session", 0); m_session = CreateSession(m_sessionId); m_context = m_manager.SystemContext.Copy(m_session.Object); } [TearDown] public void TearDown() { m_manager?.Dispose(); try { if (Directory.Exists(m_root)) { Directory.Delete(m_root, recursive: true); } } catch (IOException) { // best-effort } } private FileObjectState CreateFileState(string providerPath, string? content = null) { string full = Path.Combine(m_root, providerPath.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(full)!); File.WriteAllText(full, content ?? string.Empty); NodeId nodeId = FileSystemNodeId.BuildFile(providerPath, m_manager.NamespaceIndex); return new FileObjectState(m_context, nodeId, providerPath, Path.GetFileName(providerPath)); } private static Mock CreateSession(NodeId sessionId) { 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([]); return session; } [Test] public void OpenForReadThenReadReturnsFileContent() { FileObjectState state = CreateFileState("data.txt", "hello"); uint fileHandle = 0; ServiceResult openResult = state.Open!.OnCall!( m_context, state.Open, state.NodeId, 0x1, ref fileHandle); Assert.That(ServiceResult.IsGood(openResult), Is.True); Assert.That(fileHandle, Is.GreaterThan(0u)); ByteString data = default; ServiceResult readResult = state.Read!.OnCall!( m_context, state.Read, state.NodeId, fileHandle, 5, ref data); Assert.That(ServiceResult.IsGood(readResult), Is.True); Assert.That(Encoding.UTF8.GetString(data.ToArray()), Is.EqualTo("hello")); } [Test] public void OpenReturnsDistinctOpaqueHandles() { FileObjectState state = CreateFileState("data.txt", "hello"); uint firstHandle = 0; uint secondHandle = 0; ServiceResult firstResult = state.Open!.OnCall!( m_context, state.Open, state.NodeId, 0x1, ref firstHandle); ServiceResult secondResult = state.Open.OnCall!( m_context, state.Open, state.NodeId, 0x1, ref secondHandle); Assert.That(ServiceResult.IsGood(firstResult), Is.True); Assert.That(ServiceResult.IsGood(secondResult), Is.True); Assert.That(firstHandle, Is.Not.Zero); Assert.That(secondHandle, Is.Not.Zero); Assert.That(secondHandle, Is.Not.EqualTo(firstHandle)); } [TestCase("DATA.TXT")] [TestCase("data.txt.")] [TestCase("data.txt.. ")] public async Task FileAliasesShareOpenCountAndWriterExclusionOnWindowsAsync(string alternatePath) { FileObjectState original = CreateFileState("data.txt", "payload"); bool sameFile = Path.DirectorySeparatorChar == '\\'; if (!sameFile) { // Unix mounts can also be case-insensitive; these are deliberately distinct entries. alternatePath = "distinct-" + alternatePath; await m_manager.Provider.CreateFileAsync(alternatePath, CancellationToken.None).ConfigureAwait(false); } var alternate = new FileObjectState(m_context, FileSystemNodeId.BuildFile(alternatePath, m_manager.NamespaceIndex), alternatePath, alternatePath); uint opened = await FileReadRegressionTests.OpenAsync(original, m_context, 2).ConfigureAwait(false); (ServiceResult result, DataValue value) = await alternate.OpenCount!.ReadAttributeAsync( m_context, Attributes.Value, NumericRange.Null, QualifiedName.Null, default).ConfigureAwait(false); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That(value.WrappedValue.TryGetValue(out ushort count), Is.True); Assert.That(count, Is.EqualTo(sameFile ? 1 : 0)); if (sameFile) { OpenMethodStateResult second = await alternate.Open!.OnCallAsync!( m_context, alternate.Open, alternate.NodeId, 6, CancellationToken.None).ConfigureAwait(false); Assert.That(second.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(second.FileHandle, Is.Zero); } FileState closingFile = sameFile ? alternate : original; Assert.That(closingFile.Close!.OnCall!( m_context, closingFile.Close, closingFile.NodeId, opened).StatusCode, Is.EqualTo(StatusCodes.Good)); } [TestCase(BrowseNames.Size)] [TestCase(BrowseNames.MimeType)] [TestCase(BrowseNames.LastModifiedTime)] [TestCase(BrowseNames.Writable)] [TestCase(BrowseNames.UserWritable)] [TestCase(BrowseNames.OpenCount)] public async Task MetadataReadsDoNotRetainFileHandleStateAsync(string property) { for (int ii = 0; ii < 128; ii++) { FileObjectState file = CreateFileState("metadata-" + ii + ".txt", "payload"); NodeState variable = file.FindChild(m_context, new QualifiedName(property))!; (ServiceResult result, DataValue value) = await variable.ReadAttributeAsync( m_context, Attributes.Value, NumericRange.Null, QualifiedName.Null, default).ConfigureAwait(false); Assert.That(result.StatusCode, Is.EqualTo( property == BrowseNames.MimeType ? StatusCodes.Uncertain : StatusCodes.Good)); Assert.That(value.StatusCode, Is.EqualTo( property == BrowseNames.MimeType ? StatusCodes.Uncertain : StatusCodes.Good)); } Assert.That(m_manager.TrackedFileCount, Is.Zero, "Read-only metadata requests must not retain one handle bag per requested path."); } [Test] public async Task ClosingTheLastStreamReleasesIdleFileHandleStateAsync() { for (int ii = 0; ii < 32; ii++) { FileObjectState file = CreateFileState("open-" + ii + ".txt", "payload"); uint opened = await FileReadRegressionTests.OpenAsync(file, m_context).ConfigureAwait(false); Assert.That(m_manager.TrackedFileCount, Is.EqualTo(1)); Assert.That(file.Close!.OnCall!(m_context, file.Close, file.NodeId, opened).StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That(m_manager.TrackedFileCount, Is.Zero); } } [Test] public void OpenWithoutSessionReturnsBadSessionIdInvalid() { FileObjectState state = CreateFileState("data.txt", "hello"); ISystemContext contextWithoutSession = m_manager.SystemContext.Copy(); uint fileHandle = 0; ServiceResult result = state.Open!.OnCall!( contextWithoutSession, state.Open, state.NodeId, 0x1, ref fileHandle); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadSessionIdInvalid)); Assert.That(fileHandle, Is.Zero); } [Test] public void CloseWithoutSessionReturnsBadSessionIdInvalid() { FileObjectState state = CreateFileState("data.txt", "hello"); uint fileHandle = 0; state.Open!.OnCall!(m_context, state.Open, state.NodeId, 0x1, ref fileHandle); ISystemContext contextWithoutSession = m_manager.SystemContext.Copy(); ServiceResult result = state.Close!.OnCall!( contextWithoutSession, state.Close, state.NodeId, fileHandle); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadSessionIdInvalid)); } [Test] public void SetPositionWithoutSessionReturnsBadSessionIdInvalid() { FileObjectState state = CreateFileState("data.txt", "hello"); uint fileHandle = 0; state.Open!.OnCall!(m_context, state.Open, state.NodeId, 0x1, ref fileHandle); ISystemContext contextWithoutSession = m_manager.SystemContext.Copy(); ServiceResult result = state.SetPosition!.OnCall!( contextWithoutSession, state.SetPosition, state.NodeId, fileHandle, 1u); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadSessionIdInvalid)); } [Test] public void GetPositionWithoutSessionReturnsBadSessionIdInvalid() { FileObjectState state = CreateFileState("data.txt", "hello"); uint fileHandle = 0; state.Open!.OnCall!(m_context, state.Open, state.NodeId, 0x1, ref fileHandle); ISystemContext contextWithoutSession = m_manager.SystemContext.Copy(); ulong position = 0; ServiceResult result = state.GetPosition!.OnCall!( contextWithoutSession, state.GetPosition, state.NodeId, fileHandle, ref position); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadSessionIdInvalid)); } [Test] public void ReadWithoutSessionReturnsBadSessionIdInvalid() { FileObjectState state = CreateFileState("data.txt", "hello"); uint fileHandle = 0; state.Open!.OnCall!(m_context, state.Open, state.NodeId, 0x1, ref fileHandle); ISystemContext contextWithoutSession = m_manager.SystemContext.Copy(); ByteString data = default; ServiceResult result = state.Read!.OnCall!( contextWithoutSession, state.Read, state.NodeId, fileHandle, 1, ref data); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadSessionIdInvalid)); } [Test] public void WriteWithoutSessionReturnsBadSessionIdInvalid() { FileObjectState state = CreateFileState("out.txt"); uint fileHandle = 0; state.Open!.OnCall!(m_context, state.Open, state.NodeId, 0x2, ref fileHandle); ISystemContext contextWithoutSession = m_manager.SystemContext.Copy(); var payload = ByteString.From([1, 2, 3]); ServiceResult result = state.Write!.OnCall!( contextWithoutSession, state.Write, state.NodeId, fileHandle, payload); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadSessionIdInvalid)); } [Test] public void FileHandleMethodsRejectDifferentSession() { FileObjectState state = CreateFileState("data.txt", "hello"); uint fileHandle = 0; ServiceResult openResult = state.Open!.OnCall!( m_context, state.Open, state.NodeId, 0x1, ref fileHandle); Assert.That(ServiceResult.IsGood(openResult), Is.True); Mock otherSession = CreateSession(new NodeId("other-file-session", 0)); ISystemContext otherContext = m_manager.SystemContext.Copy(otherSession.Object); ByteString data = default; ServiceResult readResult = state.Read!.OnCall!( otherContext, state.Read, state.NodeId, fileHandle, 1, ref data); var payload = ByteString.From([1]); ServiceResult writeResult = state.Write!.OnCall!( otherContext, state.Write, state.NodeId, fileHandle, payload); ulong position = 0; ServiceResult getPositionResult = state.GetPosition!.OnCall!( otherContext, state.GetPosition, state.NodeId, fileHandle, ref position); ServiceResult setPositionResult = state.SetPosition!.OnCall!( otherContext, state.SetPosition, state.NodeId, fileHandle, 1); ServiceResult closeResult = state.Close!.OnCall!( otherContext, state.Close, state.NodeId, fileHandle); Assert.Multiple(() => { Assert.That(readResult.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(writeResult.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(getPositionResult.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(setPositionResult.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(closeResult.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); }); } [Test] public async Task SessionClosingClosesOwnedFileHandlesAsync() { FileObjectState state = CreateFileState("data.txt", "hello"); uint fileHandle = 0; ServiceResult openResult = state.Open!.OnCall!( m_context, state.Open, state.NodeId, 0x1, ref fileHandle); Assert.That(ServiceResult.IsGood(openResult), Is.True); Mock otherSession = CreateSession(new NodeId("other-file-session", 0)); ISystemContext otherContext = m_manager.SystemContext.Copy(otherSession.Object); uint otherFileHandle = 0; ServiceResult otherOpenResult = state.Open.OnCall!( otherContext, state.Open, state.NodeId, 0x1, ref otherFileHandle); Assert.That(ServiceResult.IsGood(otherOpenResult), Is.True); var operationContext = new OperationContext(m_session.Object, DiagnosticsMasks.None); await m_manager.SessionClosingAsync( operationContext, m_sessionId, deleteSubscriptions: false).ConfigureAwait(false); ByteString data = default; ServiceResult readResult = state.Read!.OnCall!( m_context, state.Read, state.NodeId, fileHandle, 1, ref data); ServiceResult otherReadResult = state.Read.OnCall!( otherContext, state.Read, state.NodeId, otherFileHandle, 1, ref data); Variant openCount = await ReadPropertyAsync(state.OpenCount!).ConfigureAwait(false); Assert.That(readResult.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(ServiceResult.IsGood(otherReadResult), Is.True); Assert.That(openCount.TryGetValue(out ushort count), Is.True); Assert.That(count, Is.EqualTo(1)); } [Test] public void ReadWithShortRemainderTrimsBuffer() { FileObjectState state = CreateFileState("data.txt", "abc"); uint fileHandle = 0; state.Open!.OnCall!(m_context, state.Open, state.NodeId, 0x1, ref fileHandle); ByteString data = default; ServiceResult readResult = state.Read!.OnCall!( m_context, state.Read, state.NodeId, fileHandle, 100, ref data); Assert.That(ServiceResult.IsGood(readResult), Is.True); Assert.That(data.Length, Is.EqualTo(3)); } [Test] public void ReadWithNegativeLengthReturnsBadInvalidArgument() { FileObjectState state = CreateFileState("data.txt", "hello"); uint fileHandle = 0; state.Open!.OnCall!(m_context, state.Open, state.NodeId, 0x1, ref fileHandle); ByteString data = default; ServiceResult result = state.Read!.OnCall!( m_context, state.Read, state.NodeId, fileHandle, -1, ref data); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); } [Test] public void ReadWithUnknownHandleReturnsBadInvalidState() { FileObjectState state = CreateFileState("data.txt", "hello"); ByteString data = default; ServiceResult result = state.Read!.OnCall!( m_context, state.Read, state.NodeId, 999u, 4, ref data); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); } [Test] public void OpenForWriteThenWritePersistsData() { FileObjectState state = CreateFileState("out.txt"); uint fileHandle = 0; ServiceResult openResult = state.Open!.OnCall!( m_context, state.Open, state.NodeId, 0x2, ref fileHandle); Assert.That(ServiceResult.IsGood(openResult), Is.True); var payload = ByteString.From(Encoding.UTF8.GetBytes("written")); ServiceResult writeResult = state.Write!.OnCall!( m_context, state.Write, state.NodeId, fileHandle, payload); Assert.That(ServiceResult.IsGood(writeResult), Is.True); ServiceResult closeResult = state.Close!.OnCall!( m_context, state.Close, state.NodeId, fileHandle); Assert.That(ServiceResult.IsGood(closeResult), Is.True); Assert.That(File.ReadAllText(Path.Combine(m_root, "out.txt")), Is.EqualTo("written")); } [Test] public void WriteWithUnknownHandleReturnsBadInvalidState() { FileObjectState state = CreateFileState("out.txt"); var payload = ByteString.From(new byte[] { 1, 2, 3 }); ServiceResult result = state.Write!.OnCall!( m_context, state.Write, state.NodeId, 999u, payload); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); } [Test] public void SetPositionAndGetPositionRoundTrip() { FileObjectState state = CreateFileState("data.txt", "0123456789"); uint fileHandle = 0; state.Open!.OnCall!(m_context, state.Open, state.NodeId, 0x1, ref fileHandle); ServiceResult setResult = state.SetPosition!.OnCall!( m_context, state.SetPosition, state.NodeId, fileHandle, 3u); Assert.That(ServiceResult.IsGood(setResult), Is.True); ulong position = 0; ServiceResult getResult = state.GetPosition!.OnCall!( m_context, state.GetPosition, state.NodeId, fileHandle, ref position); Assert.That(ServiceResult.IsGood(getResult), Is.True); Assert.That(position, Is.EqualTo(3u)); } [Test] public void SetPositionWithUnknownHandleReturnsBadInvalidState() { FileObjectState state = CreateFileState("data.txt", "hello"); ServiceResult result = state.SetPosition!.OnCall!( m_context, state.SetPosition, state.NodeId, 999u, 1u); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); } [Test] public void GetPositionWithUnknownHandleReturnsBadInvalidState() { FileObjectState state = CreateFileState("data.txt", "hello"); ulong position = 0; ServiceResult result = state.GetPosition!.OnCall!( m_context, state.GetPosition, state.NodeId, 999u, ref position); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); } [Test] public void CloseWithInvalidHandleReturnsBadInvalidState() { FileObjectState state = CreateFileState("data.txt", "hello"); ServiceResult result = state.Close!.OnCall!( m_context, state.Close, state.NodeId, 999u); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); } [Test] public async Task SizePropertyReadReturnsFileLengthAsync() { FileObjectState state = CreateFileState("data.txt", "hello"); Variant value = await ReadPropertyAsync(state.Size!).ConfigureAwait(false); Assert.That(value.TryGetValue(out ulong size), Is.True); Assert.That(size, Is.EqualTo(5u)); } [Test] public async Task WritablePropertyReadReturnsTrueForWritableProviderAsync() { FileObjectState state = CreateFileState("data.txt", "hello"); Variant value = await ReadPropertyAsync(state.Writable!).ConfigureAwait(false); Assert.That(value.TryGetValue(out bool writable), Is.True); Assert.That(writable, Is.True); } [Test] public async Task MimeTypePropertyReadReturnsValueAsync() { FileObjectState state = CreateFileState("data.txt", "hello"); (ServiceResult result, DataValue value) = await state.MimeType!.ReadAttributeAsync( m_context, Attributes.Value, NumericRange.Null, QualifiedName.Null, default).ConfigureAwait(false); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Uncertain)); Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Uncertain)); Assert.That(value.WrappedValue.TryGetValue(out string mimeType), Is.True); Assert.That(mimeType, Is.EqualTo("text/plain")); } [Test] public async Task LastModifiedTimePropertyReadReturnsValueAsync() { FileObjectState state = CreateFileState("data.txt", "hello"); Variant value = await ReadPropertyAsync(state.LastModifiedTime!).ConfigureAwait(false); Assert.That(value.TryGetValue(out DateTimeUtc modified), Is.True); Assert.That(modified, Is.Not.Default); } [Test] public async Task OpenCountPropertyReadReturnsZeroWhenNotOpenAsync() { FileObjectState state = CreateFileState("data.txt", "hello"); Variant value = await ReadPropertyAsync(state.OpenCount!).ConfigureAwait(false); Assert.That(value.TryGetValue(out ushort count), Is.True); Assert.That(count, Is.Zero); } [Test] public void OpenWithUnavailableManagerReturnsBadInvalidState() { FileObjectState state = CreateFileState("data.txt", "hello"); var orphanContext = new SystemContext(m_telemetry) { SystemHandle = null }; uint fileHandle = 0; ServiceResult result = state.Open!.OnCall!( orphanContext, state.Open, state.NodeId, 0x1, ref fileHandle); Assert.That(result.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidState)); } [Test] public void PopulateBrowserAddsParentReferenceForNestedFile() { FileObjectState state = CreateFileState("folder/data.txt", "hello"); using INodeBrowser browser = state.CreateBrowser( m_context, null, ReferenceTypeIds.HasComponent, true, BrowseDirection.Inverse, state.BrowseName, null, false); bool foundParent = false; for (IReference? reference = browser.Next(); reference != null; reference = browser.Next()) { if (reference.ReferenceTypeId == ReferenceTypeIds.HasComponent && reference.IsInverse) { foundParent = true; } } Assert.That(foundParent, Is.True); } private async ValueTask ReadPropertyAsync(BaseVariableState variable) { (ServiceResult result, DataValue value) = await variable.ReadAttributeAsync( m_context, Attributes.Value, NumericRange.Null, QualifiedName.Null, default).ConfigureAwait(false); Assert.That(ServiceResult.IsGood(result), Is.True); return value.WrappedValue; } } }