/* ======================================================================== * 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.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.Server.FileSystem; namespace Opc.Ua.Server.Tests.FileSystem { /// /// Unit tests for the internal against a /// rooted at a temp directory. /// [TestFixture] [Category("FileSystem")] public class FileHandleTests { private const byte ModeRead = 0x1; private const byte ModeWrite = 0x2; private const byte ModeEraseExisting = 0x4; private const byte ModeAppend = 0x8; private static readonly NodeId s_sessionId = new("FileHandleSession", 0); private static readonly NodeId s_otherSessionId = new("OtherFileHandleSession", 0); private string m_root = null!; [SetUp] public void SetUp() { m_root = Path.Combine(Path.GetTempPath(), "fh-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(m_root); } [TearDown] public void TearDown() { try { if (Directory.Exists(m_root)) { Directory.Delete(m_root, recursive: true); } } catch (IOException) { // best-effort } } private PhysicalFileSystemProvider CreateProvider(bool isWritable = true) { return new PhysicalFileSystemProvider(m_root, "mount", isWritable); } private void WriteFile(string name, string content) { File.WriteAllText(Path.Combine(m_root, name), content); } [Test] public void OpenWithNoModeReturnsBadInvalidArgument() { using var handle = new FileHandle(CreateProvider(), "f.txt"); ServiceResult result = handle.Open(s_sessionId, 0x0, out uint fileHandle); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); Assert.That(fileHandle, Is.Zero); } [Test] public void OpenWithNullSessionReturnsBadSessionIdInvalid() { using var handle = new FileHandle(CreateProvider(), "f.txt"); ServiceResult result = handle.Open(NodeId.Null, ModeRead, out uint fileHandle); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid)); Assert.That(fileHandle, Is.Zero); } /// /// Spec gap Read|Write: Read and Write are independent valid mode bits (Part 20 4.2.2), /// so an unsupported combined open is Bad_NotSupported, not an invalid mode. /// [Test] public void OpenWithReadAndWriteReturnsBadNotSupported() { using var handle = new FileHandle(CreateProvider(), "f.txt"); ServiceResult result = handle.Open(s_sessionId, ModeRead | ModeWrite, out uint fileHandle); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported)); Assert.That(fileHandle, Is.Zero); } [Test] public void OpenWriteOnReadOnlyProviderReturnsBadUserAccessDenied() { WriteFile("f.txt", "abc"); using var handle = new FileHandle(CreateProvider(isWritable: false), "f.txt"); ServiceResult result = handle.Open(s_sessionId, ModeWrite, out _); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); } [Test] public void OpenReadForMissingFileReturnsBadNotFound() { using var handle = new FileHandle(CreateProvider(), "missing.txt"); ServiceResult result = handle.Open(s_sessionId, ModeRead, out _); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNotFound)); } [Test] public void OpenReadSucceedsAndDispensesHandle() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); ServiceResult result = handle.Open(s_sessionId, ModeRead, out uint fileHandle); Assert.That(ServiceResult.IsGood(result), Is.True); Assert.That(fileHandle, Is.GreaterThan(0u)); Assert.That(handle.OpenCount, Is.EqualTo(1)); Assert.That(handle.GetStream(s_sessionId, fileHandle), Is.Not.Null); } [Test] public void MultipleReadersGetDistinctHandles() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeRead, out uint first); handle.Open(s_sessionId, ModeRead, out uint second); Assert.That(first, Is.Not.EqualTo(second)); Assert.That(handle.OpenCount, Is.EqualTo(2)); } [Test] public void OpenWriteWhileReadingReturnsBadInvalidState() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeRead, out _); ServiceResult result = handle.Open(s_sessionId, ModeWrite, out _); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); } [Test] public void OpenReadWhileWritingReturnsBadNotReadable() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeWrite, out _); ServiceResult result = handle.Open(s_sessionId, ModeRead, out _); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNotReadable)); } [Test] public void OpenReadWhileWriteHandleAlreadyOpenInternallyReturnsBadNotReadable() { // Uses a provider whose streams do not take exclusive OS-level // file locks, so this exercises FileHandle's own in-memory // "already open for write" guard rather than an IOException // bubbling up from the OS. using var handle = new FileHandle(new NonExclusiveStreamProvider(), "f.txt"); handle.Open(s_sessionId, ModeWrite, out uint writeHandle); ServiceResult result = handle.Open(s_sessionId, ModeRead, out uint readHandle); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNotReadable)); Assert.That(readHandle, Is.Zero); Assert.That(handle.OpenCount, Is.EqualTo(1)); Assert.That(handle.GetStream(s_sessionId, writeHandle), Is.Not.Null); } [Test] public void OpenWriteWhileAnyHandleAlreadyOpenInternallyReturnsBadInvalidState() { // Same rationale as above, but for the write-open guard that // rejects opening for write while any reader or writer holds // the file. using var handle = new FileHandle(new NonExclusiveStreamProvider(), "f.txt"); handle.Open(s_sessionId, ModeRead, out uint readHandle); ServiceResult result = handle.Open(s_sessionId, ModeWrite, out uint writeHandle); Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); Assert.That(writeHandle, Is.Zero); Assert.That(handle.OpenCount, Is.EqualTo(1)); Assert.That(handle.GetStream(s_sessionId, readHandle), Is.Not.Null); } [Test] public void OpenWriteReturnsOpaqueHandle() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); ServiceResult result = handle.Open(s_sessionId, ModeWrite, out uint fileHandle); Assert.That(ServiceResult.IsGood(result), Is.True); Assert.That(fileHandle, Is.Not.Zero); Assert.That(handle.GetStream(s_sessionId, fileHandle), Is.Not.Null); } [Test] public void OpenWriteWithEraseExistingTruncates() { WriteFile("f.txt", "existing-content"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeWrite | ModeEraseExisting, out uint fileHandle); Stream stream = handle.GetStream(s_sessionId, fileHandle)!; byte[] payload = Encoding.UTF8.GetBytes("new"); stream.Write(payload, 0, payload.Length); handle.Close(s_sessionId, fileHandle); Assert.That(File.ReadAllText(Path.Combine(m_root, "f.txt")), Is.EqualTo("new")); } [Test] public void OpenWriteWithAppendKeepsExistingContent() { WriteFile("f.txt", "a"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeWrite | ModeAppend, out uint fileHandle); Stream stream = handle.GetStream(s_sessionId, fileHandle)!; byte[] payload = Encoding.UTF8.GetBytes("b"); stream.Write(payload, 0, payload.Length); handle.Close(s_sessionId, fileHandle); Assert.That(File.ReadAllText(Path.Combine(m_root, "f.txt")), Is.EqualTo("ab")); } [Test] public void GetStreamReturnsNullForUnknownHandle() { using var handle = new FileHandle(CreateProvider(), "f.txt"); Assert.That(handle.GetStream(s_sessionId, 999u), Is.Null); } [Test] public void GetStreamReturnsNullForDifferentSession() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeRead, out uint fileHandle); Assert.That(handle.GetStream(s_otherSessionId, fileHandle), Is.Null); Assert.That(handle.GetStream(s_sessionId, fileHandle), Is.Not.Null); } [Test] public void GetStreamReturnsNullForDifferentSessionOnWriteHandle() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeWrite, out uint fileHandle); Assert.That(handle.GetStream(s_otherSessionId, fileHandle), Is.Null); Assert.That(handle.GetStream(s_sessionId, fileHandle), Is.Not.Null); } [Test] public void CloseUnknownHandleReturnsFalse() { using var handle = new FileHandle(CreateProvider(), "f.txt"); Assert.That(handle.Close(s_sessionId, 42u), Is.False); } [Test] public void CloseReturnsFalseForDifferentSessionOnWriteHandle() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeWrite, out uint fileHandle); bool closed = handle.Close(s_otherSessionId, fileHandle); Assert.That(closed, Is.False); Assert.That(handle.OpenCount, Is.EqualTo(1)); Assert.That(handle.GetStream(s_sessionId, fileHandle), Is.Not.Null); } [Test] public void CloseReturnsFalseForDifferentSessionOnReadHandle() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeRead, out uint fileHandle); bool closed = handle.Close(s_otherSessionId, fileHandle); Assert.That(closed, Is.False); Assert.That(handle.OpenCount, Is.EqualTo(1)); Assert.That(handle.GetStream(s_sessionId, fileHandle), Is.Not.Null); } [Test] public void CloseSessionRemovesOwnedWriteHandle() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeWrite, out uint fileHandle); handle.CloseSession(s_sessionId); Assert.That(handle.OpenCount, Is.Zero); Assert.That(handle.GetStream(s_sessionId, fileHandle), Is.Null); } [Test] public void CloseSessionRemovesOwnedReadHandlesButKeepsOtherSessions() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeRead, out uint ownHandle); handle.Open(s_otherSessionId, ModeRead, out uint otherHandle); handle.CloseSession(s_sessionId); Assert.That(handle.OpenCount, Is.EqualTo(1)); Assert.That(handle.GetStream(s_sessionId, ownHandle), Is.Null); Assert.That(handle.GetStream(s_otherSessionId, otherHandle), Is.Not.Null); } [Test] public void CloseSessionForSessionWithNoOpenHandlesIsNoOp() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeRead, out uint fileHandle); handle.CloseSession(s_otherSessionId); Assert.That(handle.OpenCount, Is.EqualTo(1)); Assert.That(handle.GetStream(s_sessionId, fileHandle), Is.Not.Null); } [Test] public void CloseReaderReleasesHandle() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeRead, out uint fileHandle); bool closed = handle.Close(s_sessionId, fileHandle); Assert.That(closed, Is.True); Assert.That(handle.OpenCount, Is.Zero); Assert.That(handle.GetStream(s_sessionId, fileHandle), Is.Null); } [Test] public void CloseWriterReleasesHandle() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeWrite, out uint fileHandle); bool closed = handle.Close(s_sessionId, fileHandle); Assert.That(closed, Is.True); Assert.That(handle.OpenCount, Is.Zero); } [Test] public void MetadataPropertiesReflectFile() { WriteFile("data.json", "{}"); using var handle = new FileHandle(CreateProvider(), "data.json"); Assert.That(handle.Length, Is.EqualTo(2)); Assert.That(handle.MimeType, Is.EqualTo("application/json")); Assert.That(handle.LastModifiedTime, Is.GreaterThan(DateTime.MinValue)); Assert.That(handle.IsWriteable, Is.True); } [Test] public void MetadataPropertiesReturnDefaultsForMissingFile() { using var handle = new FileHandle(CreateProvider(), "gone.txt"); Assert.That(handle.Length, Is.Zero); Assert.That(handle.MimeType, Is.Empty); Assert.That(handle.LastModifiedTime, Is.EqualTo(DateTime.MinValue)); Assert.That(handle.IsWriteable, Is.False); } /// /// Verifies that opening a readable handle does not change the file's underlying write capability. /// [Test] public void IsWriteableIsTrueWhileOpen() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeRead, out _); Assert.That(handle.IsWriteable, Is.True); } [Test] public void IsWriteableIsFalseForReadOnlyProvider() { WriteFile("f.txt", "hello"); using var handle = new FileHandle(CreateProvider(isWritable: false), "f.txt"); Assert.That(handle.IsWriteable, Is.False); } [Test] public void DisposeClosesAllStreams() { WriteFile("f.txt", "hello"); var handle = new FileHandle(CreateProvider(), "f.txt"); handle.Open(s_sessionId, ModeRead, out _); handle.Open(s_sessionId, ModeRead, out _); handle.Dispose(); Assert.That(handle.OpenCount, Is.Zero); } /// /// Minimal fake whose read/write /// streams are independent in-memory buffers rather than OS file /// handles. Unlike , opening /// the same path twice never fails at the OS level, which lets /// tests drive 's own in-memory /// already-open guards instead of the IOException fallback path. /// private sealed class NonExclusiveStreamProvider : IFileSystemProvider { public string MountName => "mount"; public bool IsWritable => true; public ValueTask GetEntryAsync(string path, CancellationToken ct) { return new ValueTask((FileSystemEntry?)null); } public async IAsyncEnumerable EnumerateAsync( string path, [EnumeratorCancellation] CancellationToken ct) { await Task.CompletedTask.ConfigureAwait(false); yield break; } public ValueTask OpenReadAsync(string path, CancellationToken ct) { return new ValueTask((Stream)new MemoryStream()); } public ValueTask OpenWriteAsync(string path, FileWriteMode mode, CancellationToken ct) { return new ValueTask((Stream)new MemoryStream()); } 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(); } } } }