From 96f7afb9b488fc20cc5b82f5c19298f788c02260 Mon Sep 17 00:00:00 2001 From: Mpdreamz Date: Sun, 20 Sep 2026 13:49:43 +0200 Subject: [PATCH 01/17] feat: add mock support for `OpenHandle` and `IRandomAccess` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the abstractions added alongside for `MockFileSystem`, so the `SafeFileHandle` surface can be exercised against the mock. `SafeFileHandle` is sealed and wraps an operating system handle, so the mock cannot create one a real syscall would accept. It hands out a handle with a synthetic value and remembers which file that value stands for — the same indirection `ISafeFileHandleStrategy` already provides for handles created elsewhere, which continues to work unchanged. A handle resolves to the file it was opened on rather than to its name, so it keeps working across a rename and does not start referring to a file later created under the original path. File share locks are released and `FileOptions.DeleteOnClose` applies when the last handle to a file closes, noticed on the next file system operation because a sealed `SafeFileHandle` gives no disposal callback. Tests run against both the real and the mocked file system, covering every member that takes a handle. Co-Authored-By: Claude Opus 5 --- .../FileSystem/FileMock.cs | 35 +- .../FileSystem/FileStreamFactoryMock.cs | 21 +- .../FileSystem/FileStreamMock.cs | 22 +- .../FileSystem/MockSafeFileHandleRegistry.cs | 367 ++++++++++++++++++ .../FileSystem/RandomAccessMock.cs | 318 +++++++++++++++ .../Helpers/ExceptionFactory.cs | 6 + .../Helpers/FileModeHelper.cs | 33 ++ .../MockFileSystem.cs | 36 +- .../Statistics/FileSystemStatistics.cs | 13 + .../Statistics/IFileSystemStatistics.cs | 7 + .../Testably.Abstractions.Testing.csproj | 4 +- .../Testably.Abstractions.Testing_net10.0.txt | 2 + .../Testably.Abstractions.Testing_net6.0.txt | 2 + .../Testably.Abstractions.Testing_net8.0.txt | 2 + .../Testably.Abstractions.Testing_net9.0.txt | 2 + .../Testably.Abstractions.TestHelpers.csproj | 4 +- .../FileSystem/FileStatisticsTests.cs | 25 ++ ...Testably.Abstractions.Testing.Tests.csproj | 2 +- .../FileSystem/File/OpenHandleTests.cs | 266 +++++++++++++ .../FileSystem/File/SafeFileHandleTests.cs | 283 ++++++++++++++ .../OpenHandleStreamTests.cs | 98 +++++ .../FileSystem/RandomAccess/AppendTests.cs | 68 ++++ .../RandomAccess/HandleIdentityTests.cs | 82 ++++ .../FileSystem/RandomAccess/LengthTests.cs | 135 +++++++ .../FileSystem/RandomAccess/ReadTests.cs | 208 ++++++++++ .../FileSystem/RandomAccess/Tests.cs | 123 ++++++ .../FileSystem/RandomAccess/WriteTests.cs | 195 ++++++++++ .../Testably.Abstractions.Tests.csproj | 2 +- 28 files changed, 2313 insertions(+), 48 deletions(-) create mode 100644 Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs create mode 100644 Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs create mode 100644 Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs create mode 100644 Tests/Testably.Abstractions.Tests/FileSystem/File/OpenHandleTests.cs create mode 100644 Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs create mode 100644 Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/OpenHandleStreamTests.cs create mode 100644 Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/AppendTests.cs create mode 100644 Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs create mode 100644 Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/LengthTests.cs create mode 100644 Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/ReadTests.cs create mode 100644 Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/Tests.cs create mode 100644 Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/WriteTests.cs diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileMock.cs index 7e9bb1ae5..08d94784b 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileMock.cs @@ -6,7 +6,7 @@ using System.Text; using Testably.Abstractions.Testing.Helpers; using Testably.Abstractions.Testing.Storage; -#if FEATURE_FILESYSTEM_SAFEFILEHANDLE +#if FEATURE_FILESYSTEM_SAFEFILEHANDLE || FEATURE_FILESYSTEM_RANDOMACCESS using Microsoft.Win32.SafeHandles; #endif #if FEATURE_FILESYSTEM_ASYNC @@ -827,6 +827,24 @@ public FileSystemStream Open(string path, FileStreamOptions options) } #endif +#if FEATURE_FILESYSTEM_RANDOMACCESS + /// + public SafeFileHandle OpenHandle(string path, + FileMode mode = FileMode.Open, + FileAccess access = FileAccess.Read, + FileShare share = FileShare.Read, + FileOptions options = FileOptions.None, + long preallocationSize = 0) + { + using IDisposable registration = _fileSystem.StatisticsRegistration + .File.RegisterMethod(nameof(OpenHandle), + path, mode, access, share, options, preallocationSize); + + return _fileSystem.SafeFileHandleRegistry + .Open(path, mode, access, share, options, preallocationSize); + } +#endif + /// public FileSystemStream OpenRead(string path) { @@ -1671,20 +1689,7 @@ private IStorageContainer GetContainerFromPath(string path, #if FEATURE_FILESYSTEM_SAFEFILEHANDLE private IStorageContainer GetContainerFromSafeFileHandle(SafeFileHandle fileHandle) - { - SafeFileHandleMock safeFileHandleMock = _fileSystem - .SafeFileHandleStrategy.MapSafeFileHandle(fileHandle); - IStorageContainer container = _fileSystem.Storage - .GetContainer(_fileSystem.Storage.GetLocation( - safeFileHandleMock.Path) - .ThrowExceptionIfNotFound(_fileSystem)); - if (container is NullContainer) - { - throw ExceptionFactory.FileNotFound(""); - } - - return container; - } + => _fileSystem.SafeFileHandleRegistry.GetContainer(fileHandle).Container; #endif private void WriteText(string path, string? contents, Encoding? encoding, bool append = false) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileStreamFactoryMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileStreamFactoryMock.cs index d2d02918c..31d18875f 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileStreamFactoryMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileStreamFactoryMock.cs @@ -153,8 +153,7 @@ public FileSystemStream New(SafeFileHandle handle, FileAccess access) .FileStream.RegisterMethod(nameof(New), handle, access); - SafeFileHandleMock safeFileHandleMock = _fileSystem - .SafeFileHandleStrategy.MapSafeFileHandle(handle); + SafeFileHandleMock safeFileHandleMock = MapSafeFileHandle(handle); return New( safeFileHandleMock.Path, safeFileHandleMock.Mode, @@ -172,8 +171,7 @@ public FileSystemStream New(SafeFileHandle handle, FileAccess access, int buffer .FileStream.RegisterMethod(nameof(New), handle, access, bufferSize); - SafeFileHandleMock safeFileHandleMock = _fileSystem - .SafeFileHandleStrategy.MapSafeFileHandle(handle); + SafeFileHandleMock safeFileHandleMock = MapSafeFileHandle(handle); return New( safeFileHandleMock.Path, safeFileHandleMock.Mode, @@ -193,8 +191,7 @@ public FileSystemStream New(SafeFileHandle handle, FileAccess access, int buffer .FileStream.RegisterMethod(nameof(New), handle, access, bufferSize, isAsync); - SafeFileHandleMock safeFileHandleMock = _fileSystem - .SafeFileHandleStrategy.MapSafeFileHandle(handle); + SafeFileHandleMock safeFileHandleMock = MapSafeFileHandle(handle); return New( safeFileHandleMock.Path, safeFileHandleMock.Mode, @@ -234,4 +231,16 @@ public FileSystemStream Wrap(FileStream fileStream) } #endregion + + /// + /// Resolves the file behind , preferring a handle that the + /// created itself and falling back to the registered + /// . + /// + private SafeFileHandleMock MapSafeFileHandle(SafeFileHandle handle) +#if FEATURE_FILESYSTEM_RANDOMACCESS + => _fileSystem.SafeFileHandleRegistry.Map(handle); +#else + => _fileSystem.SafeFileHandleStrategy.MapSafeFileHandle(handle); +#endif } diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs index 8dd8fb970..02d5de944 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs @@ -901,25 +901,5 @@ private void ThrowIfDisposed() } private static void ThrowIfInvalidModeAccess(FileMode mode, FileAccess access) - { - if (mode == FileMode.Append) - { - if (access == FileAccess.Read) - { - throw ExceptionFactory.InvalidAccessCombination(mode, access); - } - - if (access != FileAccess.Write) - { - throw ExceptionFactory.AppendAccessOnlyInWriteOnlyMode(); - } - } - - if (!access.HasFlag(FileAccess.Write) && - (mode == FileMode.Truncate || mode == FileMode.CreateNew || - mode == FileMode.Create || mode == FileMode.Append)) - { - throw ExceptionFactory.InvalidAccessCombination(mode, access); - } - } + => FileModeHelper.ThrowIfInvalidModeAccess(mode, access); } diff --git a/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs new file mode 100644 index 000000000..1115aacb0 --- /dev/null +++ b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs @@ -0,0 +1,367 @@ +#if FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections.Generic; +using System.IO; +using Testably.Abstractions.Testing.Helpers; +using Testably.Abstractions.Testing.Storage; + +namespace Testably.Abstractions.Testing.FileSystem; + +/// +/// Keeps track of the s that the created itself in +/// . +/// +/// A is sealed and wraps an operating system handle, so the mock cannot create one +/// that a real syscall would accept. Instead it hands out a handle with a synthetic value and remembers which file +/// that value stands for, which is the same indirection that provides for +/// handles created outside of the . +/// +internal sealed class MockSafeFileHandleRegistry +{ + /// + /// Synthetic handle values start far above any plausible file descriptor or handle value, so that a mock handle + /// which is accidentally passed to a real syscall fails with an invalid-handle error rather than addressing an + /// unrelated file. + /// + private const long FirstHandleValue = 0x4000_0000L; + + private readonly Dictionary _entries = new(); + private readonly MockFileSystem _fileSystem; +#if NET9_0_OR_GREATER + private readonly System.Threading.Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + /// + /// Files whose last handle has been closed while other handles were + /// still open on them. They are deleted once the last of those closes. + /// + private readonly List _pendingDeletes = []; + + private volatile bool _hasWork; + private long _nextHandleValue = FirstHandleValue; + private bool _sweeping; + + internal MockSafeFileHandleRegistry(MockFileSystem fileSystem) + { + _fileSystem = fileSystem; + } + + /// + /// Opens and returns a that this registry can resolve + /// back to the file. + /// + internal SafeFileHandle Open(string path, + FileMode mode, + FileAccess access, + FileShare share, + FileOptions options, + long preallocationSize) + { + if (preallocationSize < 0) + { + throw ExceptionFactory.NonNegativeNumberRequired("preallocationSize"); + } + + FileModeHelper.ThrowIfInvalidModeAccess(mode, access); + + // Release the share locks of handles that were closed since the last access, before requesting a new one. + ReleaseClosedHandles(); + + IStorageLocation location = _fileSystem.Storage + .GetLocation(path.EnsureValidFormat(_fileSystem)); + location.ThrowExceptionIfNotFound(_fileSystem, true); + + IStorageContainer container = _fileSystem.Storage.GetContainer(location); + if (container is NullContainer) + { + if (mode == FileMode.Open || mode == FileMode.Truncate) + { + throw ExceptionFactory.FileNotFound(location.FullPath); + } + + container = _fileSystem.Storage + .GetOrCreateContainer(location, InMemoryContainer.NewFile); + } + else if (container.Type == FileSystemTypes.Directory) + { + if (_fileSystem.Execute.IsWindows) + { + throw ExceptionFactory.AccessToPathDenied(location.FullPath); + } + + throw ExceptionFactory.FileAlreadyExists(location.FullPath, 17); + } + else if (mode == FileMode.CreateNew) + { + throw ExceptionFactory.FileAlreadyExists(location.FullPath, + _fileSystem.Execute.IsWindows ? -2147024816 : 17); + } + + if (container.Attributes.HasFlag(FileAttributes.ReadOnly) && + access.HasFlag(FileAccess.Write)) + { + throw ExceptionFactory.AccessToPathDenied(location.FullPath); + } + + // `deleteAccess` marks a delete *operation* — `FileHandle.GrantAccess` requires every other handle to have + // been opened with exactly `FileShare.Delete` on Windows — so it is not what `FileOptions.DeleteOnClose` + // means. Opening for deletion-on-close is an ordinary open; the deletion happens when the handle closes. + IStorageAccessHandle accessLock = container.RequestAccess(access, share); + + if (mode == FileMode.Create || mode == FileMode.Truncate) + { + container.WriteBytes([]); + } + + lock (_lock) + { + IntPtr value = new(_nextHandleValue++); + SafeFileHandle handle = new(value, ownsHandle: false); + _entries[value] = new Entry( + handle, + new SafeFileHandleMock(location.FullPath, mode, share), + accessLock, + location, + container, + access, + mode, + options); + _hasWork = true; + return handle; + } + } + + /// + /// Resolves a that this registry created, or returns if the + /// handle originated elsewhere and has to be mapped by the instead. + /// + internal Entry? Resolve(SafeFileHandle handle) + { + ReleaseClosedHandles(); + + lock (_lock) + { + IntPtr value = handle.DangerousGetHandle(); + if (_entries.TryGetValue(value, out Entry? entry)) + { + if (handle.IsClosed) + { + throw ExceptionFactory.HandleIsClosed(); + } + + return entry; + } + + // Handle values are issued sequentially and never reused, so a value within the issued range that is no + // longer registered belonged to a handle this registry created and the caller has since closed. + if (WasIssued(value)) + { + throw ExceptionFactory.HandleIsClosed(); + } + + return null; + } + } + + /// + /// Indicates whether was created by this registry, irrespective of whether it is + /// still open. + /// + internal bool IsKnown(SafeFileHandle handle) + { + lock (_lock) + { + IntPtr value = handle.DangerousGetHandle(); + return _entries.ContainsKey(value) || WasIssued(value); + } + } + + /// + /// Maps to the file it refers to, either because this registry created it or, + /// failing that, by asking the registered . + /// + internal SafeFileHandleMock Map(SafeFileHandle handle) + => Resolve(handle)?.Mock + ?? _fileSystem.SafeFileHandleStrategy.MapSafeFileHandle(handle); + + /// + /// Returns the file that refers to, together with the and + /// the handle was opened with. + /// + /// + /// A handle refers to the file that was opened, not to its name: it keeps working when the file is renamed, and + /// does not start referring to whatever is later created under the original path. A handle created by this + /// registry is therefore resolved to the container it was opened on, and only a handle that originated + /// elsewhere — which carries nothing but a path, and no access information — is looked up by name. + /// + internal (IStorageContainer Container, FileAccess Access, FileMode Mode) GetContainer( + SafeFileHandle handle) + { + if (Resolve(handle) is { } entry) + { + return (entry.Container, entry.Access, entry.Mode); + } + + SafeFileHandleMock mock = _fileSystem.SafeFileHandleStrategy.MapSafeFileHandle(handle); + IStorageContainer container = _fileSystem.Storage + .GetContainer(_fileSystem.Storage.GetLocation(mock.Path) + .ThrowExceptionIfNotFound(_fileSystem)); + if (container is NullContainer) + { + throw ExceptionFactory.FileNotFound(""); + } + + return (container, FileAccess.ReadWrite, mock.Mode); + } + + /// + /// Releases the file share locks held by handles that the caller has since disposed, and applies + /// for them. + /// + /// is sealed, so the mock cannot be notified when one is closed and instead + /// notices on the next registry access. + /// + internal void ReleaseClosedHandles() + { + if (!_hasWork) + { + return; + } + + List? released = null; + lock (_lock) + { + if (_sweeping) + { + return; + } + + List? closed = null; + foreach (KeyValuePair item in _entries) + { + if (item.Value.Handle.IsClosed) + { + (closed ??= []).Add(item.Key); + } + } + + if (closed is null) + { + return; + } + + foreach (IntPtr key in closed) + { + (released ??= []).Add(_entries[key]); + _entries.Remove(key); + } + + _sweeping = true; + } + + try + { + foreach (Entry entry in released!) + { + entry.AccessLock.Dispose(); + if (entry.Options.HasFlag(FileOptions.DeleteOnClose)) + { + _pendingDeletes.Add(entry); + } + } + + // The file is removed when the last handle to it is closed, not the first, so a deletion stays pending + // until no handle refers to the file any more. + for (int i = _pendingDeletes.Count - 1; i >= 0; i--) + { + Entry pending = _pendingDeletes[i]; + if (IsStillOpen(pending.Container)) + { + continue; + } + + _pendingDeletes.RemoveAt(i); + _fileSystem.Storage.DeleteContainer(pending.Location, FileSystemTypes.File); + } + } + finally + { + lock (_lock) + { + _sweeping = false; + _hasWork = _entries.Count > 0 || _pendingDeletes.Count > 0; + } + } + } + + private bool IsStillOpen(IStorageContainer container) + { + lock (_lock) + { + foreach (KeyValuePair item in _entries) + { + if (ReferenceEquals(item.Value.Container, container)) + { + return true; + } + } + + return false; + } + } + + /// + /// Indicates whether lies in the range of handle values this registry has issued. + /// + private bool WasIssued(IntPtr value) + { + long candidate = value.ToInt64(); + return candidate >= FirstHandleValue && candidate < _nextHandleValue; + } + + /// + /// The file behind a created by the . + /// + internal sealed class Entry + { + internal Entry(SafeFileHandle handle, + SafeFileHandleMock mock, + IStorageAccessHandle accessLock, + IStorageLocation location, + IStorageContainer container, + FileAccess access, + FileMode mode, + FileOptions options) + { + Handle = handle; + Mock = mock; + AccessLock = accessLock; + Location = location; + Container = container; + Access = access; + Mode = mode; + Options = options; + } + + internal FileAccess Access { get; } + internal IStorageAccessHandle AccessLock { get; } + internal IStorageContainer Container { get; } + internal SafeFileHandle Handle { get; } + + /// + /// The location the handle was opened on. + /// + /// + /// Only used to apply . A container does not expose its current + /// location, so a file renamed while such a handle is open is deleted under its original name. + /// + internal IStorageLocation Location { get; } + + internal FileMode Mode { get; } + internal SafeFileHandleMock Mock { get; } + internal FileOptions Options { get; } + } +} +#endif diff --git a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs new file mode 100644 index 000000000..470acfb4f --- /dev/null +++ b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs @@ -0,0 +1,318 @@ +#if FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Testably.Abstractions.Testing.Helpers; +using Testably.Abstractions.Testing.Storage; + +namespace Testably.Abstractions.Testing.FileSystem; + +internal sealed class RandomAccessMock : IRandomAccess +{ + private readonly MockFileSystem _fileSystem; + + internal RandomAccessMock(MockFileSystem fileSystem) + { + _fileSystem = fileSystem; + } + + #region IRandomAccess Members + + /// + public IFileSystem FileSystem + => _fileSystem; + +#if FEATURE_RANDOMACCESS_FLUSHTODISK + /// + public void FlushToDisk(SafeFileHandle handle) + { + using IDisposable registration = _fileSystem.StatisticsRegistration + .RandomAccess.RegisterMethod(nameof(FlushToDisk), handle); + + // The in-memory storage has no write-back cache, so there is nothing to flush. The call is still resolved + // and counted, so that a test can assert that a durability barrier was requested. + _ = GetContainer(handle, FileAccess.Write); + } +#endif + + /// + public long GetLength(SafeFileHandle handle) + { + using IDisposable registration = _fileSystem.StatisticsRegistration + .RandomAccess.RegisterMethod(nameof(GetLength), handle); + + // Querying the length is a metadata operation: it works on a write-only handle too. + return GetContainer(handle, required: null).GetBytes().Length; + } + + /// + public int Read(SafeFileHandle handle, Span buffer, long fileOffset) + { + using IDisposable registration = _fileSystem.StatisticsRegistration + .RandomAccess.RegisterMethod(nameof(Read), handle, fileOffset); + + byte[] bytes = ReadBytes(handle, fileOffset); + int count = Math.Min(buffer.Length, bytes.Length); + bytes.AsSpan(0, count).CopyTo(buffer); + return count; + } + + /// + public long Read(SafeFileHandle handle, IReadOnlyList> buffers, long fileOffset) + { + using IDisposable registration = _fileSystem.StatisticsRegistration + .RandomAccess.RegisterMethod(nameof(Read), handle, fileOffset); + + if (buffers is null) + { + throw new ArgumentNullException(nameof(buffers)); + } + + byte[] bytes = ReadBytes(handle, fileOffset); + return Scatter(bytes, buffers); + } + + /// + public ValueTask ReadAsync(SafeFileHandle handle, Memory buffer, long fileOffset, + CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return ValueTask.FromCanceled(cancellationToken); + } + + using IDisposable registration = _fileSystem.StatisticsRegistration + .RandomAccess.RegisterMethod(nameof(ReadAsync), handle, fileOffset, + cancellationToken); + + return new ValueTask(Read(handle, buffer.Span, fileOffset)); + } + + /// + public ValueTask ReadAsync(SafeFileHandle handle, IReadOnlyList> buffers, + long fileOffset, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return ValueTask.FromCanceled(cancellationToken); + } + + using IDisposable registration = _fileSystem.StatisticsRegistration + .RandomAccess.RegisterMethod(nameof(ReadAsync), handle, fileOffset, + cancellationToken); + + return new ValueTask(Read(handle, buffers, fileOffset)); + } + +#if FEATURE_FILESYSTEM_NET_7_OR_GREATER + /// + public void SetLength(SafeFileHandle handle, long length) + { + using IDisposable registration = _fileSystem.StatisticsRegistration + .RandomAccess.RegisterMethod(nameof(SetLength), handle, length); + + if (length < 0) + { + throw ExceptionFactory.NonNegativeNumberRequired("length"); + } + + IStorageContainer container = GetContainer(handle, FileAccess.Write); + byte[] bytes = container.GetBytes(); + byte[] resized = new byte[length]; + Array.Copy(bytes, resized, Math.Min(bytes.Length, length)); + container.WriteBytes(resized); + } +#endif + + /// + public void Write(SafeFileHandle handle, ReadOnlySpan buffer, long fileOffset) + { + using IDisposable registration = _fileSystem.StatisticsRegistration + .RandomAccess.RegisterMethod(nameof(Write), handle, fileOffset); + + WriteBytes(handle, buffer.ToArray(), fileOffset); + } + + /// + public void Write(SafeFileHandle handle, IReadOnlyList> buffers, + long fileOffset) + { + using IDisposable registration = _fileSystem.StatisticsRegistration + .RandomAccess.RegisterMethod(nameof(Write), handle, fileOffset); + + if (buffers is null) + { + throw new ArgumentNullException(nameof(buffers)); + } + + WriteBytes(handle, Gather(buffers), fileOffset); + } + + /// + public ValueTask WriteAsync(SafeFileHandle handle, ReadOnlyMemory buffer, + long fileOffset, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return ValueTask.FromCanceled(cancellationToken); + } + + using IDisposable registration = _fileSystem.StatisticsRegistration + .RandomAccess.RegisterMethod(nameof(WriteAsync), handle, fileOffset, + cancellationToken); + + Write(handle, buffer.Span, fileOffset); + return default; + } + + /// + public ValueTask WriteAsync(SafeFileHandle handle, + IReadOnlyList> buffers, + long fileOffset, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return ValueTask.FromCanceled(cancellationToken); + } + + using IDisposable registration = _fileSystem.StatisticsRegistration + .RandomAccess.RegisterMethod(nameof(WriteAsync), handle, fileOffset, + cancellationToken); + + Write(handle, buffers, fileOffset); + return default; + } + + #endregion + + private static long Scatter(byte[] bytes, IReadOnlyList> buffers) + { + int position = 0; + foreach (Memory buffer in buffers) + { + if (position >= bytes.Length) + { + break; + } + + int count = Math.Min(buffer.Length, bytes.Length - position); + bytes.AsSpan(position, count).CopyTo(buffer.Span); + position += count; + } + + return position; + } + + private static byte[] Gather(IReadOnlyList> buffers) + { + int length = 0; + foreach (ReadOnlyMemory buffer in buffers) + { + length += buffer.Length; + } + + byte[] bytes = new byte[length]; + int position = 0; + foreach (ReadOnlyMemory buffer in buffers) + { + buffer.Span.CopyTo(bytes.AsSpan(position)); + position += buffer.Length; + } + + return bytes; + } + + private IStorageContainer GetContainer(SafeFileHandle handle, FileAccess? required) + => Resolve(handle, required).Container; + + private (IStorageContainer Container, FileMode Mode) Resolve( + SafeFileHandle handle, FileAccess? required) + { + if (handle is null) + { + throw new ArgumentNullException(nameof(handle)); + } + + if (handle.IsInvalid) + { + throw ExceptionFactory.HandleIsInvalid(); + } + + (IStorageContainer container, FileAccess access, FileMode mode) = + _fileSystem.SafeFileHandleRegistry.GetContainer(handle); + + if (required is { } requiredAccess && !access.HasFlag(requiredAccess)) + { + throw ExceptionFactory.AccessToPathDenied(); + } + + return (container, mode); + } + + /// + /// Returns the bytes of the file that refers to, starting at + /// ; an offset at or beyond the end yields an empty result, which is the short + /// read that reports as zero bytes. + /// + private byte[] ReadBytes(SafeFileHandle handle, long fileOffset) + { + if (fileOffset < 0) + { + throw ExceptionFactory.NonNegativeNumberRequired("fileOffset"); + } + + byte[] bytes = GetContainer(handle, FileAccess.Read).GetBytes(); + if (fileOffset >= bytes.Length) + { + return []; + } + + byte[] result = new byte[bytes.Length - fileOffset]; + Array.Copy(bytes, fileOffset, result, 0, result.Length); + return result; + } + + /// + /// Writes at , growing the file and zero-filling any + /// gap between the previous end and the offset. + /// + /// + /// On a handle opened with the offset is ignored on Linux, whose + /// pwrite(2) appends to the end of the file when the descriptor carries O_APPEND, contrary to + /// POSIX. Windows and macOS honour the offset. + /// + private void WriteBytes(SafeFileHandle handle, byte[] buffer, long fileOffset) + { + if (fileOffset < 0) + { + throw ExceptionFactory.NonNegativeNumberRequired("fileOffset"); + } + + (IStorageContainer container, FileMode mode) = Resolve(handle, FileAccess.Write); + if (buffer.Length == 0) + { + return; + } + + byte[] bytes = container.GetBytes(); + if (mode == FileMode.Append && _fileSystem.Execute.IsLinux) + { + fileOffset = bytes.Length; + } + + long required = fileOffset + buffer.Length; + if (required > bytes.Length) + { + byte[] grown = new byte[required]; + Array.Copy(bytes, grown, bytes.Length); + bytes = grown; + } + + Array.Copy(buffer, 0, bytes, fileOffset, buffer.Length); + container.WriteBytes(bytes); + } +} +#endif diff --git a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs index 5d0cf6488..95fc9d7d9 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs @@ -102,6 +102,12 @@ internal static IOException FileSharingViolation(string path) #endif }; + internal static ArgumentOutOfRangeException NonNegativeNumberRequired(string paramName) + => new(paramName, "Non-negative number required."); + + internal static ObjectDisposedException HandleIsClosed() + => new("SafeHandle", "Safe handle has been closed."); + internal static ArgumentException HandleIsInvalid(string? paramName = "handle") => new("Invalid handle.", paramName); diff --git a/Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs b/Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs new file mode 100644 index 000000000..8f86084bd --- /dev/null +++ b/Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs @@ -0,0 +1,33 @@ +using System.IO; + +namespace Testably.Abstractions.Testing.Helpers; + +internal static class FileModeHelper +{ + /// + /// Validates that the and combination is allowed when + /// opening a file. + /// + internal static void ThrowIfInvalidModeAccess(FileMode mode, FileAccess access) + { + if (mode == FileMode.Append) + { + if (access == FileAccess.Read) + { + throw ExceptionFactory.InvalidAccessCombination(mode, access); + } + + if (access != FileAccess.Write) + { + throw ExceptionFactory.AppendAccessOnlyInWriteOnlyMode(); + } + } + + if (!access.HasFlag(FileAccess.Write) && + (mode == FileMode.Truncate || mode == FileMode.CreateNew || + mode == FileMode.Create || mode == FileMode.Append)) + { + throw ExceptionFactory.InvalidAccessCombination(mode, access); + } + } +} diff --git a/Source/Testably.Abstractions.Testing/MockFileSystem.cs b/Source/Testably.Abstractions.Testing/MockFileSystem.cs index d50e88bf0..119a6c07b 100644 --- a/Source/Testably.Abstractions.Testing/MockFileSystem.cs +++ b/Source/Testably.Abstractions.Testing/MockFileSystem.cs @@ -84,6 +84,13 @@ internal IAccessControlStrategy AccessControlStrategy internal FileSystemRegistration Registration { get; } +#if FEATURE_FILESYSTEM_RANDOMACCESS + /// + /// Tracks the s that this created itself. + /// + internal MockSafeFileHandleRegistry SafeFileHandleRegistry { get; } +#endif + internal ISafeFileHandleStrategy SafeFileHandleStrategy { get; @@ -95,7 +102,18 @@ internal ISafeFileHandleStrategy SafeFileHandleStrategy /// /// The underlying storage of directories and files. /// - internal IStorage Storage => _storage; + internal IStorage Storage + { + get + { +#if FEATURE_FILESYSTEM_RANDOMACCESS + // A `SafeFileHandle` is sealed, so the mock cannot be notified when one is closed. Noticing here means + // that any file system operation observes the released file share locks and `FileOptions.DeleteOnClose`. + SafeFileHandleRegistry.ReleaseClosedHandles(); +#endif + return _storage; + } + } /// /// The registered containers in the in-Memory . @@ -114,6 +132,9 @@ internal IUnixFileModeStrategy UnixFileModeStrategy private readonly DirectoryMock _directoryMock; private readonly FileMock _fileMock; private readonly PathMock _pathMock; +#if FEATURE_FILESYSTEM_RANDOMACCESS + private readonly RandomAccessMock _randomAccessMock; +#endif private readonly InMemoryStorage _storage; /// @@ -146,6 +167,10 @@ public MockFileSystem(Func options TimeSystem = initialization.TimeSystem ?? new MockTimeSystem(TimeProviderFactory.Now()); _pathMock = new PathMock(this); _storage = new InMemoryStorage(this); +#if FEATURE_FILESYSTEM_RANDOMACCESS + // Created before anything can access `Storage`, which sweeps the registry for closed handles. + SafeFileHandleRegistry = new MockSafeFileHandleRegistry(this); +#endif ChangeHandler = new ChangeHandler(this, initialization.RecordNotificationHistory); _directoryMock = new DirectoryMock(this); _fileMock = new FileMock(this); @@ -156,6 +181,9 @@ public MockFileSystem(Func options FileSystemWatcher = new FileSystemWatcherFactoryMock(this); FileVersionInfo = new FileVersionInfoFactoryMock(this); SafeFileHandleStrategy = new NullSafeFileHandleStrategy(); +#if FEATURE_FILESYSTEM_RANDOMACCESS + _randomAccessMock = new RandomAccessMock(this); +#endif AccessControlStrategy = new NullAccessControlStrategy(); #if FEATURE_FILESYSTEM_UNIXFILEMODE UnixFileModeStrategy = new NullUnixFileModeStrategy(); @@ -195,6 +223,12 @@ public IFile File public IPath Path => _pathMock; +#if FEATURE_FILESYSTEM_RANDOMACCESS + /// + public IRandomAccess RandomAccess + => _randomAccessMock; +#endif + #endregion /// diff --git a/Source/Testably.Abstractions.Testing/Statistics/FileSystemStatistics.cs b/Source/Testably.Abstractions.Testing/Statistics/FileSystemStatistics.cs index d8b4ac26b..3aa724331 100644 --- a/Source/Testably.Abstractions.Testing/Statistics/FileSystemStatistics.cs +++ b/Source/Testably.Abstractions.Testing/Statistics/FileSystemStatistics.cs @@ -15,6 +15,9 @@ internal readonly PathStatistics FileVersionInfo; internal readonly CallStatistics Path; +#if FEATURE_FILESYSTEM_RANDOMACCESS + internal readonly CallStatistics RandomAccess; +#endif private readonly MockFileSystem _fileSystem; public FileSystemStatistics(MockFileSystem fileSystem) @@ -40,6 +43,10 @@ public FileSystemStatistics(MockFileSystem fileSystem) statisticsGate, fileSystem, nameof(IFileSystem.FileVersionInfo)); Path = new CallStatistics( statisticsGate, nameof(IFileSystem.Path)); +#if FEATURE_FILESYSTEM_RANDOMACCESS + RandomAccess = new CallStatistics( + statisticsGate, nameof(IFileSystem.RandomAccess)); +#endif } #region IFileSystemStatistics Members @@ -86,5 +93,11 @@ IPathStatistics IFileSystemStatistics.File IStatistics IFileSystemStatistics.Path => Path; +#if FEATURE_FILESYSTEM_RANDOMACCESS + /// + IStatistics IFileSystemStatistics.RandomAccess + => RandomAccess; +#endif + #endregion } diff --git a/Source/Testably.Abstractions.Testing/Statistics/IFileSystemStatistics.cs b/Source/Testably.Abstractions.Testing/Statistics/IFileSystemStatistics.cs index ce5211140..46525e2f9 100644 --- a/Source/Testably.Abstractions.Testing/Statistics/IFileSystemStatistics.cs +++ b/Source/Testably.Abstractions.Testing/Statistics/IFileSystemStatistics.cs @@ -50,6 +50,13 @@ public interface IFileSystemStatistics /// IStatistics Path { get; } +#if FEATURE_FILESYSTEM_RANDOMACCESS + /// + /// Statistical information about calls to . + /// + IStatistics RandomAccess { get; } +#endif + /// /// The sum of all registered statistic calls. /// diff --git a/Source/Testably.Abstractions.Testing/Testably.Abstractions.Testing.csproj b/Source/Testably.Abstractions.Testing/Testably.Abstractions.Testing.csproj index 592577a8d..5d8622bd4 100644 --- a/Source/Testably.Abstractions.Testing/Testably.Abstractions.Testing.csproj +++ b/Source/Testably.Abstractions.Testing/Testably.Abstractions.Testing.csproj @@ -10,7 +10,9 @@ - + + diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net10.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net10.0.txt index bc2059aec..9cfd27e23 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net10.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net10.0.txt @@ -65,6 +65,7 @@ namespace Testably.Abstractions.Testing public Testably.Abstractions.Testing.FileSystem.IInterceptionHandler Intercept { get; } public Testably.Abstractions.Testing.FileSystem.INotificationHandler Notify { get; } public System.IO.Abstractions.IPath Path { get; } + public System.IO.Abstractions.IRandomAccess RandomAccess { get; } public Testably.Abstractions.IRandomSystem RandomSystem { get; } public Testably.Abstractions.Testing.SimulationMode SimulationMode { get; } public Testably.Abstractions.Testing.Statistics.IFileSystemStatistics Statistics { get; } @@ -390,6 +391,7 @@ namespace Testably.Abstractions.Testing.Statistics Testably.Abstractions.Testing.Statistics.IPathStatistics FileSystemWatcher { get; } Testably.Abstractions.Testing.Statistics.IPathStatistics FileVersionInfo { get; } Testably.Abstractions.Testing.Statistics.IStatistics Path { get; } + Testably.Abstractions.Testing.Statistics.IStatistics RandomAccess { get; } int TotalCount { get; } } public interface IPathStatistics : Testably.Abstractions.Testing.Statistics.IStatistics, Testably.Abstractions.Testing.Statistics.IStatistics diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net6.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net6.0.txt index 50fbcf3a7..8abada810 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net6.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net6.0.txt @@ -65,6 +65,7 @@ namespace Testably.Abstractions.Testing public Testably.Abstractions.Testing.FileSystem.IInterceptionHandler Intercept { get; } public Testably.Abstractions.Testing.FileSystem.INotificationHandler Notify { get; } public System.IO.Abstractions.IPath Path { get; } + public System.IO.Abstractions.IRandomAccess RandomAccess { get; } public Testably.Abstractions.IRandomSystem RandomSystem { get; } public Testably.Abstractions.Testing.SimulationMode SimulationMode { get; } public Testably.Abstractions.Testing.Statistics.IFileSystemStatistics Statistics { get; } @@ -375,6 +376,7 @@ namespace Testably.Abstractions.Testing.Statistics Testably.Abstractions.Testing.Statistics.IPathStatistics FileSystemWatcher { get; } Testably.Abstractions.Testing.Statistics.IPathStatistics FileVersionInfo { get; } Testably.Abstractions.Testing.Statistics.IStatistics Path { get; } + Testably.Abstractions.Testing.Statistics.IStatistics RandomAccess { get; } int TotalCount { get; } } public interface IPathStatistics : Testably.Abstractions.Testing.Statistics.IStatistics, Testably.Abstractions.Testing.Statistics.IStatistics diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net8.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net8.0.txt index ace219b4c..6be0b04f9 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net8.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net8.0.txt @@ -65,6 +65,7 @@ namespace Testably.Abstractions.Testing public Testably.Abstractions.Testing.FileSystem.IInterceptionHandler Intercept { get; } public Testably.Abstractions.Testing.FileSystem.INotificationHandler Notify { get; } public System.IO.Abstractions.IPath Path { get; } + public System.IO.Abstractions.IRandomAccess RandomAccess { get; } public Testably.Abstractions.IRandomSystem RandomSystem { get; } public Testably.Abstractions.Testing.SimulationMode SimulationMode { get; } public Testably.Abstractions.Testing.Statistics.IFileSystemStatistics Statistics { get; } @@ -390,6 +391,7 @@ namespace Testably.Abstractions.Testing.Statistics Testably.Abstractions.Testing.Statistics.IPathStatistics FileSystemWatcher { get; } Testably.Abstractions.Testing.Statistics.IPathStatistics FileVersionInfo { get; } Testably.Abstractions.Testing.Statistics.IStatistics Path { get; } + Testably.Abstractions.Testing.Statistics.IStatistics RandomAccess { get; } int TotalCount { get; } } public interface IPathStatistics : Testably.Abstractions.Testing.Statistics.IStatistics, Testably.Abstractions.Testing.Statistics.IStatistics diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net9.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net9.0.txt index 111e3991a..36f1a18ed 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net9.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net9.0.txt @@ -65,6 +65,7 @@ namespace Testably.Abstractions.Testing public Testably.Abstractions.Testing.FileSystem.IInterceptionHandler Intercept { get; } public Testably.Abstractions.Testing.FileSystem.INotificationHandler Notify { get; } public System.IO.Abstractions.IPath Path { get; } + public System.IO.Abstractions.IRandomAccess RandomAccess { get; } public Testably.Abstractions.IRandomSystem RandomSystem { get; } public Testably.Abstractions.Testing.SimulationMode SimulationMode { get; } public Testably.Abstractions.Testing.Statistics.IFileSystemStatistics Statistics { get; } @@ -390,6 +391,7 @@ namespace Testably.Abstractions.Testing.Statistics Testably.Abstractions.Testing.Statistics.IPathStatistics FileSystemWatcher { get; } Testably.Abstractions.Testing.Statistics.IPathStatistics FileVersionInfo { get; } Testably.Abstractions.Testing.Statistics.IStatistics Path { get; } + Testably.Abstractions.Testing.Statistics.IStatistics RandomAccess { get; } int TotalCount { get; } } public interface IPathStatistics : Testably.Abstractions.Testing.Statistics.IStatistics, Testably.Abstractions.Testing.Statistics.IStatistics diff --git a/Tests/Helpers/Testably.Abstractions.TestHelpers/Testably.Abstractions.TestHelpers.csproj b/Tests/Helpers/Testably.Abstractions.TestHelpers/Testably.Abstractions.TestHelpers.csproj index 31a77762b..cc360f959 100644 --- a/Tests/Helpers/Testably.Abstractions.TestHelpers/Testably.Abstractions.TestHelpers.csproj +++ b/Tests/Helpers/Testably.Abstractions.TestHelpers/Testably.Abstractions.TestHelpers.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/Tests/Testably.Abstractions.Testing.Tests/Statistics/FileSystem/FileStatisticsTests.cs b/Tests/Testably.Abstractions.Testing.Tests/Statistics/FileSystem/FileStatisticsTests.cs index 3bc5812b1..419e43cf0 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/Statistics/FileSystem/FileStatisticsTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/Statistics/FileSystem/FileStatisticsTests.cs @@ -5,6 +5,8 @@ using Testably.Abstractions.Testing.Tests.TestHelpers; #if FEATURE_FILESYSTEM_SAFEFILEHANDLE using Testably.Abstractions.Testing.FileSystem; +#endif +#if FEATURE_FILESYSTEM_SAFEFILEHANDLE || FEATURE_FILESYSTEM_RANDOMACCESS using Microsoft.Win32.SafeHandles; #endif #if FEATURE_FILESYSTEM_ASYNC @@ -817,6 +819,29 @@ await That(sut.Statistics.File).OnlyContainsMethodCall(nameof(IFile.Open), } #endif +#if FEATURE_FILESYSTEM_RANDOMACCESS + [Test] + public async Task + Method_OpenHandle_String_FileMode_FileAccess_FileShare_FileOptions_Int64_ShouldRegisterCall() + { + MockFileSystem sut = new(); + sut.Initialize().WithFile("foo"); + string path = "foo"; + FileMode mode = FileMode.Open; + FileAccess access = FileAccess.Read; + FileShare share = FileShare.Read; + FileOptions options = FileOptions.None; + long preallocationSize = 0; + + using SafeFileHandle handle = + sut.File.OpenHandle(path, mode, access, share, options, preallocationSize); + + await That(sut.Statistics.TotalCount).IsEqualTo(1); + await That(sut.Statistics.File).OnlyContainsMethodCall(nameof(IFile.OpenHandle), + path, mode, access, share, options, preallocationSize); + } +#endif + [Test] public async Task Method_OpenRead_String_ShouldRegisterCall() { diff --git a/Tests/Testably.Abstractions.Testing.Tests/Testably.Abstractions.Testing.Tests.csproj b/Tests/Testably.Abstractions.Testing.Tests/Testably.Abstractions.Testing.Tests.csproj index 70b2b5912..6f14aca89 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/Testably.Abstractions.Testing.Tests.csproj +++ b/Tests/Testably.Abstractions.Testing.Tests/Testably.Abstractions.Testing.Tests.csproj @@ -13,7 +13,7 @@ - + diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/File/OpenHandleTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/File/OpenHandleTests.cs new file mode 100644 index 000000000..6cfd7b4fb --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/File/OpenHandleTests.cs @@ -0,0 +1,266 @@ +#if FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System.IO; + +namespace Testably.Abstractions.Tests.FileSystem.File; + +[FileSystemTests] +public class OpenHandleTests(FileSystemTestData testData) : FileSystemTestBase(testData) +{ + [Test] + [AutoArguments] + public async Task OpenHandle_ShouldReturnOpenHandle(string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(handle.IsInvalid).IsFalse(); + await That(handle.IsClosed).IsFalse(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_ShouldDefaultToOpeningExistingFileForReading( + string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(FileSystem.RandomAccess.GetLength(handle)) + .IsEqualTo(FileSystem.File.ReadAllBytes(path).Length); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WhenFileIsMissing_ShouldThrowFileNotFoundException(string path) + { + void Act() + { + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + } + + await That(Act).Throws(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WithCreateNew_WhenFileExists_ShouldThrowIOException( + string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + void Act() + { + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.CreateNew, FileAccess.Write); + } + + await That(Act).Throws(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WithCreate_ShouldCreateMissingFile(string path) + { + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Create, FileAccess.Write)) + { + await That(handle.IsInvalid).IsFalse(); + } + + await That(FileSystem.File.Exists(path)).IsTrue(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WithCreate_WhenFileExists_ShouldTruncateContent( + string path, string contents) + { + Skip.If(string.IsNullOrEmpty(contents)); + FileSystem.File.WriteAllText(path, contents); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Create, FileAccess.Write)) + { + await That(FileSystem.RandomAccess.GetLength(handle)).IsEqualTo(0L); + } + + await That(FileSystem.File.ReadAllBytes(path)).IsEmpty(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WithTruncate_ShouldEmptyExistingFile( + string path, string contents) + { + Skip.If(string.IsNullOrEmpty(contents)); + FileSystem.File.WriteAllText(path, contents); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Truncate, FileAccess.Write)) + { + await That(FileSystem.RandomAccess.GetLength(handle)).IsEqualTo(0L); + } + + await That(FileSystem.File.ReadAllBytes(path)).IsEmpty(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WithTruncate_WhenFileIsMissing_ShouldThrowFileNotFoundException( + string path) + { + void Act() + { + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Truncate, FileAccess.Write); + } + + await That(Act).Throws(); + } + + [Test] + [AutoArguments(FileMode.Truncate)] + [AutoArguments(FileMode.CreateNew)] + [AutoArguments(FileMode.Create)] + [AutoArguments(FileMode.Append)] + public async Task OpenHandle_WithReadAccess_WhenModeRequiresWriting_ShouldThrowArgumentException( + FileMode mode, string path) + { + void Act() + { + using SafeFileHandle handle = + FileSystem.File.OpenHandle(path, mode, FileAccess.Read); + } + + await That(Act).Throws(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WithNegativePreallocationSize_ShouldThrowArgumentOutOfRangeException( + string path) + { + void Act() + { + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Create, FileAccess.Write, FileShare.None, FileOptions.None, -1); + } + + await That(Act).Throws(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WhenDisposed_ShouldBeClosed(string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + SafeFileHandle handle = FileSystem.File.OpenHandle(path); + handle.Dispose(); + + await That(handle.IsClosed).IsTrue(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WhenDisposed_ShouldNotBeUsableAnyMore( + string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + SafeFileHandle handle = FileSystem.File.OpenHandle(path); + handle.Dispose(); + + void Act() => FileSystem.RandomAccess.GetLength(handle); + + await That(Act).Throws(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WithDeleteOnClose_ShouldDeleteFileWhenClosed(string path) + { + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Create, FileAccess.Write, FileShare.None, FileOptions.DeleteOnClose)) + { + await That(FileSystem.File.Exists(path)).IsTrue(); + } + + await That(FileSystem.File.Exists(path)).IsFalse(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_TwoHandlesOnSameFile_ShouldShareContent( + string path, byte[] bytes) + { + Skip.If(bytes.Length == 0); + FileSystem.File.WriteAllBytes(path, []); + + using SafeFileHandle writer = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write, FileShare.ReadWrite); + using SafeFileHandle reader = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + FileSystem.RandomAccess.Write(writer, bytes, 0); + + byte[] buffer = new byte[bytes.Length]; + int read = FileSystem.RandomAccess.Read(reader, buffer, 0); + + await That(read).IsEqualTo(bytes.Length); + await That(buffer).IsEqualTo(bytes); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WithFileShareNone_ShouldBlockSecondHandle( + string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle first = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Read, FileShare.None); + + void Act() + { + using SafeFileHandle second = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Read, FileShare.None); + } + + await That(Act).Throws(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_AfterClose_ShouldReleaseFileShareLock( + string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + SafeFileHandle first = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Read, FileShare.None); + first.Dispose(); + + using SafeFileHandle second = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Read, FileShare.None); + + await That(second.IsInvalid).IsFalse(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_ShouldBeUsableByFileMethodsTakingAHandle( + string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(FileSystem.File.GetAttributes(handle)) + .IsEqualTo(FileSystem.File.GetAttributes(path)); + } +} +#endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs new file mode 100644 index 000000000..a6e1fd580 --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs @@ -0,0 +1,283 @@ +#if FEATURE_FILESYSTEM_SAFEFILEHANDLE && FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System.IO; + +namespace Testably.Abstractions.Tests.FileSystem.File; + +/// +/// Covers every member that takes a . +/// +/// These could not be exercised against the before, because there was no way to +/// obtain a handle from the abstraction. +/// +[FileSystemTests] +public class SafeFileHandleTests(FileSystemTestData testData) : FileSystemTestBase(testData) +{ + private SafeFileHandle OpenReadWrite(string path) + => FileSystem.File.OpenHandle(path, FileMode.Open, FileAccess.ReadWrite); + + [Test] + [AutoArguments] + public async Task GetAttributes_ShouldMatchThePathOverload(string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(FileSystem.File.GetAttributes(handle)) + .IsEqualTo(FileSystem.File.GetAttributes(path)); + } + + [Test] + [AutoArguments(FileAttributes.ReadOnly)] + [AutoArguments(FileAttributes.Normal)] + public async Task SetAttributes_ShouldChangeAttributes( + FileAttributes attributes, string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using (SafeFileHandle handle = OpenReadWrite(path)) + { + FileSystem.File.SetAttributes(handle, attributes); + } + + await That(FileSystem.File.GetAttributes(path)).IsEqualTo(attributes); + } + + [Test] + [AutoArguments] + public async Task GetCreationTime_ShouldMatchThePathOverload(string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(FileSystem.File.GetCreationTime(handle)) + .IsEqualTo(FileSystem.File.GetCreationTime(path)); + } + + [Test] + [AutoArguments] + public async Task GetCreationTimeUtc_ShouldMatchThePathOverload(string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(FileSystem.File.GetCreationTimeUtc(handle)) + .IsEqualTo(FileSystem.File.GetCreationTimeUtc(path)); + } + + [Test] + [AutoArguments] + public async Task SetCreationTime_ShouldChangeCreationTime(string path, DateTime creationTime) + { + Skip.IfNot(Test.RunsOnWindows, + "Linux does not have a creation timestamp: https://unix.stackexchange.com/a/102692"); + + creationTime = creationTime.ToLocalTime(); + DateTime expectedTime = creationTime.ToUniversalTime(); + FileSystem.File.WriteAllText(path, null); + + using (SafeFileHandle handle = OpenReadWrite(path)) + { + FileSystem.File.SetCreationTime(handle, creationTime); + } + + await That(FileSystem.File.GetCreationTimeUtc(path)).IsEqualTo(expectedTime); + } + + [Test] + [AutoArguments] + public async Task SetCreationTimeUtc_ShouldChangeCreationTime( + string path, DateTime creationTime) + { + Skip.IfNot(Test.RunsOnWindows, + "Linux does not have a creation timestamp: https://unix.stackexchange.com/a/102692"); + + creationTime = creationTime.ToUniversalTime(); + FileSystem.File.WriteAllText(path, null); + + using (SafeFileHandle handle = OpenReadWrite(path)) + { + FileSystem.File.SetCreationTimeUtc(handle, creationTime); + } + + await That(FileSystem.File.GetCreationTimeUtc(path)).IsEqualTo(creationTime); + } + + [Test] + [AutoArguments] + public async Task GetLastAccessTime_ShouldMatchThePathOverload(string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(FileSystem.File.GetLastAccessTime(handle)) + .IsEqualTo(FileSystem.File.GetLastAccessTime(path)); + } + + [Test] + [AutoArguments] + public async Task GetLastAccessTimeUtc_ShouldMatchThePathOverload(string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(FileSystem.File.GetLastAccessTimeUtc(handle)) + .IsEqualTo(FileSystem.File.GetLastAccessTimeUtc(path)); + } + + [Test] + [AutoArguments] + public async Task SetLastAccessTime_ShouldChangeLastAccessTime( + string path, DateTime lastAccessTime) + { + lastAccessTime = lastAccessTime.ToLocalTime(); + DateTime expectedTime = lastAccessTime.ToUniversalTime(); + FileSystem.File.WriteAllText(path, null); + + using (SafeFileHandle handle = OpenReadWrite(path)) + { + FileSystem.File.SetLastAccessTime(handle, lastAccessTime); + } + + await That(FileSystem.File.GetLastAccessTimeUtc(path)).IsEqualTo(expectedTime); + } + + [Test] + [AutoArguments] + public async Task SetLastAccessTimeUtc_ShouldChangeLastAccessTime( + string path, DateTime lastAccessTime) + { + lastAccessTime = lastAccessTime.ToUniversalTime(); + FileSystem.File.WriteAllText(path, null); + + using (SafeFileHandle handle = OpenReadWrite(path)) + { + FileSystem.File.SetLastAccessTimeUtc(handle, lastAccessTime); + } + + await That(FileSystem.File.GetLastAccessTimeUtc(path)).IsEqualTo(lastAccessTime); + } + + [Test] + [AutoArguments] + public async Task GetLastWriteTime_ShouldMatchThePathOverload(string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(FileSystem.File.GetLastWriteTime(handle)) + .IsEqualTo(FileSystem.File.GetLastWriteTime(path)); + } + + [Test] + [AutoArguments] + public async Task GetLastWriteTimeUtc_ShouldMatchThePathOverload(string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(FileSystem.File.GetLastWriteTimeUtc(handle)) + .IsEqualTo(FileSystem.File.GetLastWriteTimeUtc(path)); + } + + [Test] + [AutoArguments] + public async Task SetLastWriteTime_ShouldChangeLastWriteTime( + string path, DateTime lastWriteTime) + { + lastWriteTime = lastWriteTime.ToLocalTime(); + DateTime expectedTime = lastWriteTime.ToUniversalTime(); + FileSystem.File.WriteAllText(path, null); + + using (SafeFileHandle handle = OpenReadWrite(path)) + { + FileSystem.File.SetLastWriteTime(handle, lastWriteTime); + } + + await That(FileSystem.File.GetLastWriteTimeUtc(path)).IsEqualTo(expectedTime); + } + + [Test] + [AutoArguments] + public async Task SetLastWriteTimeUtc_ShouldChangeLastWriteTime( + string path, DateTime lastWriteTime) + { + lastWriteTime = lastWriteTime.ToUniversalTime(); + FileSystem.File.WriteAllText(path, null); + + using (SafeFileHandle handle = OpenReadWrite(path)) + { + FileSystem.File.SetLastWriteTimeUtc(handle, lastWriteTime); + } + + await That(FileSystem.File.GetLastWriteTimeUtc(path)).IsEqualTo(lastWriteTime); + } + +#if FEATURE_FILESYSTEM_UNIXFILEMODE + [Test] + [AutoArguments] + [UnsupportedOSPlatform("windows")] + public async Task GetUnixFileMode_ShouldMatchThePathOverload(string path, string contents) + { + Skip.If(Test.RunsOnWindows, "UnixFileMode is not supported on Windows"); + + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(FileSystem.File.GetUnixFileMode(handle)) + .IsEqualTo(FileSystem.File.GetUnixFileMode(path)); + } + + [Test] + [AutoArguments] + [UnsupportedOSPlatform("windows")] + public async Task SetUnixFileMode_ShouldChangeUnixFileMode(string path, string contents) + { + Skip.If(Test.RunsOnWindows, "UnixFileMode is not supported on Windows"); + + FileSystem.File.WriteAllText(path, contents); + const UnixFileMode mode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + using (SafeFileHandle handle = OpenReadWrite(path)) + { + FileSystem.File.SetUnixFileMode(handle, mode); + } + + await That(FileSystem.File.GetUnixFileMode(path)).IsEqualTo(mode); + } +#endif + + [Test] + [AutoArguments] + public async Task AllHandleOverloads_ShouldAgreeWithThePathOverloads( + string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(FileSystem.File.GetAttributes(handle)) + .IsEqualTo(FileSystem.File.GetAttributes(path)); + await That(FileSystem.File.GetCreationTime(handle)) + .IsEqualTo(FileSystem.File.GetCreationTime(path)); + await That(FileSystem.File.GetCreationTimeUtc(handle)) + .IsEqualTo(FileSystem.File.GetCreationTimeUtc(path)); + await That(FileSystem.File.GetLastAccessTime(handle)) + .IsEqualTo(FileSystem.File.GetLastAccessTime(path)); + await That(FileSystem.File.GetLastAccessTimeUtc(handle)) + .IsEqualTo(FileSystem.File.GetLastAccessTimeUtc(path)); + await That(FileSystem.File.GetLastWriteTime(handle)) + .IsEqualTo(FileSystem.File.GetLastWriteTime(path)); + await That(FileSystem.File.GetLastWriteTimeUtc(handle)) + .IsEqualTo(FileSystem.File.GetLastWriteTimeUtc(path)); + } +} +#endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/OpenHandleStreamTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/OpenHandleStreamTests.cs new file mode 100644 index 000000000..539039767 --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/OpenHandleStreamTests.cs @@ -0,0 +1,98 @@ +#if FEATURE_FILESYSTEM_SAFEFILEHANDLE && FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System.IO; + +namespace Testably.Abstractions.Tests.FileSystem.FileStreamFactory; + +/// +/// Covers the overloads that take a , using a +/// handle obtained from the abstraction itself. +/// +/// The equivalent tests in SafeFileHandleTests are compiled out behind +/// EXECUTE_SAFEFILEHANDLE_TESTS, because creating a handle used to require platform invocation. +/// +/// The handles are opened with : a real adopts the +/// handle it is given, whereas re-opens the path behind it, so it needs the file +/// to be shareable with the handle that is already open on it. +/// +[FileSystemTests] +public class OpenHandleStreamTests(FileSystemTestData testData) : FileSystemTestBase(testData) +{ + [Test] + [AutoArguments] + public async Task New_WithHandle_ShouldReadTheFileContent(string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using FileSystemStream stream = FileSystem.FileStream.New(handle, FileAccess.Read); + using StreamReader reader = new(stream); + + await That(reader.ReadToEnd()).IsEqualTo(contents); + } + + [Test] + [AutoArguments] + public async Task New_WithHandleAndBufferSize_ShouldReadTheFileContent( + string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using FileSystemStream stream = FileSystem.FileStream.New(handle, FileAccess.Read, 1024); + using StreamReader reader = new(stream); + + await That(reader.ReadToEnd()).IsEqualTo(contents); + } + + [Test] + [AutoArguments] + public async Task New_WithHandleAndBufferSizeAndAsync_ShouldReadTheFileContent( + string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using FileSystemStream stream = + FileSystem.FileStream.New(handle, FileAccess.Read, 1024, false); + using StreamReader reader = new(stream); + + await That(reader.ReadToEnd()).IsEqualTo(contents); + } + + [Test] + [AutoArguments] + public async Task New_WithHandle_ShouldCreateWritableStream(string path) + { + FileSystem.File.WriteAllText(path, null); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) + { + using FileSystemStream stream = + FileSystem.FileStream.New(handle, FileAccess.ReadWrite); + stream.Write(new byte[] { 1, 2, 3, }, 0, 3); + } + + await That(FileSystem.File.ReadAllBytes(path)).IsEqualTo(new byte[] { 1, 2, 3, }); + } + + [Test] + [AutoArguments] + public async Task New_WithHandle_ShouldReportAccessFromTheGivenFileAccess( + string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using FileSystemStream stream = FileSystem.FileStream.New(handle, FileAccess.Read); + + await That(stream.CanRead).IsTrue(); + await That(stream.CanWrite).IsFalse(); + } +} +#endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/AppendTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/AppendTests.cs new file mode 100644 index 000000000..6593a759d --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/AppendTests.cs @@ -0,0 +1,68 @@ +#if FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System.IO; + +namespace Testably.Abstractions.Tests.FileSystem.RandomAccess; + +/// +/// Writing through a handle opened with . +/// +/// Linux deviates from POSIX here: its pwrite(2) appends to the end of the file when the descriptor +/// carries O_APPEND, whatever offset is passed. Windows and macOS honour the offset. +/// +[FileSystemTests] +public class AppendTests(FileSystemTestData testData) : FileSystemTestBase(testData) +{ + [Test] + [AutoArguments] + public async Task Write_OnAppendHandle_ShouldAppend_OnLinux(string path) + { + Skip.IfNot(Test.RunsOnLinux, "only Linux appends regardless of the offset"); + + FileSystem.File.WriteAllBytes(path, [1, 2, 3, 4,]); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Append, FileAccess.Write)) + { + FileSystem.RandomAccess.Write(handle, new byte[] { 9, }, 0); + } + + await That(FileSystem.File.ReadAllBytes(path)) + .IsEqualTo(new byte[] { 1, 2, 3, 4, 9, }); + } + + [Test] + [AutoArguments] + public async Task Write_OnAppendHandle_ShouldHonourTheOffset_OnWindowsAndMac(string path) + { + Skip.If(Test.RunsOnLinux, "Linux appends regardless of the offset"); + + FileSystem.File.WriteAllBytes(path, [1, 2, 3, 4,]); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Append, FileAccess.Write)) + { + FileSystem.RandomAccess.Write(handle, new byte[] { 9, }, 0); + } + + await That(FileSystem.File.ReadAllBytes(path)) + .IsEqualTo(new byte[] { 9, 2, 3, 4, }); + } + + [Test] + [AutoArguments] + public async Task Write_OnAppendHandle_ShouldAppendToAnEmptyFileRegardlessOfPlatform( + string path) + { + FileSystem.File.WriteAllBytes(path, []); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Append, FileAccess.Write)) + { + FileSystem.RandomAccess.Write(handle, new byte[] { 7, 8, }, 0); + } + + await That(FileSystem.File.ReadAllBytes(path)).IsEqualTo(new byte[] { 7, 8, }); + } +} +#endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs new file mode 100644 index 000000000..7e52d12ba --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs @@ -0,0 +1,82 @@ +#if FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System.IO; + +namespace Testably.Abstractions.Tests.FileSystem.RandomAccess; + +/// +/// A handle refers to the file that was opened, not to its name. +/// +[FileSystemTests] +public class HandleIdentityTests(FileSystemTestData testData) : FileSystemTestBase(testData) +{ + [Test] + [AutoArguments] + public async Task Handle_ShouldKeepWorking_WhenTheFileIsRenamed(string path, string other) + { + Skip.If(Test.RunsOnWindows, + "the mock tracks file share locks by path, so it refuses to rename or delete a file that a handle is open on, even when the handle permits delete sharing"); + + FileSystem.File.WriteAllBytes(path, [1, 2, 3, 4,]); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete); + + FileSystem.File.Move(path, other); + + byte[] buffer = new byte[4]; + int read = FileSystem.RandomAccess.Read(handle, buffer, 0); + + await That(read).IsEqualTo(4); + await That(buffer).IsEqualTo(new byte[] { 1, 2, 3, 4, }); + } + + [Test] + [AutoArguments] + public async Task Handle_ShouldNotFollowTheName_WhenAnotherFileTakesTheOldPath( + string path, string other) + { + Skip.If(Test.RunsOnWindows, + "the mock tracks file share locks by path, so it refuses to rename or delete a file that a handle is open on, even when the handle permits delete sharing"); + + FileSystem.File.WriteAllBytes(path, [1, 2, 3, 4,]); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete); + + FileSystem.File.Move(path, other); + FileSystem.File.WriteAllBytes(path, [9, 9, 9, 9,]); + + byte[] buffer = new byte[4]; + FileSystem.RandomAccess.Read(handle, buffer, 0); + + await That(buffer).IsEqualTo(new byte[] { 1, 2, 3, 4, }) + .Because("the handle refers to the file it was opened on, not to the path"); + } + + [Test] + [AutoArguments] + public async Task DeleteOnClose_ShouldDeleteOnlyWhenTheLastHandleIsClosed(string path) + { + Skip.If(Test.RunsOnWindows, + "the mock tracks file share locks by path, so it refuses to rename or delete a file that a handle is open on, even when the handle permits delete sharing"); + + FileSystem.File.WriteAllText(path, null); + + SafeFileHandle first = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete, + FileOptions.DeleteOnClose); + SafeFileHandle second = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete); + + first.Dispose(); + + await That(FileSystem.File.Exists(path)).IsTrue() + .Because("a second handle is still open on the file"); + + second.Dispose(); + + await That(FileSystem.File.Exists(path)).IsFalse(); + } +} +#endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/LengthTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/LengthTests.cs new file mode 100644 index 000000000..39337c9e2 --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/LengthTests.cs @@ -0,0 +1,135 @@ +#if FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System.IO; + +namespace Testably.Abstractions.Tests.FileSystem.RandomAccess; + +[FileSystemTests] +public class LengthTests(FileSystemTestData testData) : FileSystemTestBase(testData) +{ + [Test] + [AutoArguments] + public async Task GetLength_ShouldReturnTheFileLength(string path, byte[] contents) + { + FileSystem.File.WriteAllBytes(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(FileSystem.RandomAccess.GetLength(handle)).IsEqualTo((long)contents.Length); + } + + [Test] + [AutoArguments] + public async Task GetLength_OnEmptyFile_ShouldReturnZero(string path) + { + FileSystem.File.WriteAllBytes(path, []); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + await That(FileSystem.RandomAccess.GetLength(handle)).IsEqualTo(0L); + } + + [Test] + [AutoArguments] + public async Task GetLength_OnWriteOnlyHandle_ShouldStillReturnTheLength( + string path, byte[] contents) + { + FileSystem.File.WriteAllBytes(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write); + + await That(FileSystem.RandomAccess.GetLength(handle)).IsEqualTo((long)contents.Length); + } + + [Test] + [AutoArguments] + public async Task GetLength_AfterWriting_ShouldReflectTheNewLength(string path) + { + FileSystem.File.WriteAllBytes(path, []); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write); + + FileSystem.RandomAccess.Write(handle, new byte[] { 1, 2, 3, }, 0); + + await That(FileSystem.RandomAccess.GetLength(handle)).IsEqualTo(3L); + } + +#if FEATURE_FILESYSTEM_NET_7_OR_GREATER + [Test] + [AutoArguments] + public async Task SetLength_ShouldTruncateTheFile(string path) + { + FileSystem.File.WriteAllBytes(path, [1, 2, 3, 4, 5,]); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write)) + { + FileSystem.RandomAccess.SetLength(handle, 2); + } + + await That(FileSystem.File.ReadAllBytes(path)).IsEqualTo(new byte[] { 1, 2, }); + } + + [Test] + [AutoArguments] + public async Task SetLength_ShouldGrowTheFileWithZeroes(string path) + { + FileSystem.File.WriteAllBytes(path, [1, 2,]); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write)) + { + FileSystem.RandomAccess.SetLength(handle, 4); + } + + await That(FileSystem.File.ReadAllBytes(path)).IsEqualTo(new byte[] { 1, 2, 0, 0, }); + } + + [Test] + [AutoArguments] + public async Task SetLength_ToZero_ShouldEmptyTheFile(string path, byte[] contents) + { + FileSystem.File.WriteAllBytes(path, contents); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write)) + { + FileSystem.RandomAccess.SetLength(handle, 0); + } + + await That(FileSystem.File.ReadAllBytes(path)).IsEmpty(); + } + + [Test] + [AutoArguments] + public async Task SetLength_WithNegativeLength_ShouldThrowArgumentOutOfRangeException( + string path) + { + FileSystem.File.WriteAllBytes(path, [1,]); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write); + + void Act() => FileSystem.RandomAccess.SetLength(handle, -1); + + await That(Act).Throws(); + } + + [Test] + [AutoArguments] + public async Task SetLength_WithReadOnlyHandle_ShouldThrowUnauthorizedAccessException( + string path) + { + FileSystem.File.WriteAllBytes(path, [1, 2, 3,]); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + void Act() => FileSystem.RandomAccess.SetLength(handle, 1); + + await That(Act).Throws(); + } +#endif +} +#endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/ReadTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/ReadTests.cs new file mode 100644 index 000000000..d417d09d1 --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/ReadTests.cs @@ -0,0 +1,208 @@ +#if FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System.Collections.Generic; +using System.IO; + +namespace Testably.Abstractions.Tests.FileSystem.RandomAccess; + +[FileSystemTests] +public class ReadTests(FileSystemTestData testData) : FileSystemTestBase(testData) +{ + [Test] + [AutoArguments] + public async Task Read_ShouldReadFromTheGivenOffset(string path) + { + byte[] contents = [1, 2, 3, 4, 5, 6, 7, 8,]; + FileSystem.File.WriteAllBytes(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + byte[] buffer = new byte[4]; + int read = FileSystem.RandomAccess.Read(handle, buffer, 2); + + await That(read).IsEqualTo(4); + await That(buffer).IsEqualTo(new byte[] { 3, 4, 5, 6, }); + } + + [Test] + [AutoArguments] + public async Task Read_ShouldNotAdvanceAnyPosition(string path) + { + byte[] contents = [1, 2, 3, 4,]; + FileSystem.File.WriteAllBytes(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + byte[] first = new byte[2]; + byte[] second = new byte[2]; + FileSystem.RandomAccess.Read(handle, first, 0); + FileSystem.RandomAccess.Read(handle, second, 0); + + await That(first).IsEqualTo(second); + } + + [Test] + [AutoArguments] + public async Task Read_WhenBufferIsLargerThanTheRemainder_ShouldReturnShortRead(string path) + { + byte[] contents = [1, 2, 3,]; + FileSystem.File.WriteAllBytes(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + byte[] buffer = new byte[10]; + int read = FileSystem.RandomAccess.Read(handle, buffer, 1); + + await That(read).IsEqualTo(2); + await That(buffer[0]).IsEqualTo((byte)2); + await That(buffer[1]).IsEqualTo((byte)3); + } + + [Test] + [AutoArguments] + public async Task Read_WhenOffsetIsBeyondTheEnd_ShouldReturnZero(string path) + { + byte[] contents = [1, 2, 3,]; + FileSystem.File.WriteAllBytes(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + byte[] buffer = new byte[4]; + int read = FileSystem.RandomAccess.Read(handle, buffer, 10); + + await That(read).IsEqualTo(0); + } + + [Test] + [AutoArguments] + public async Task Read_WhenOffsetIsAtTheEnd_ShouldReturnZero(string path) + { + byte[] contents = [1, 2, 3,]; + FileSystem.File.WriteAllBytes(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + byte[] buffer = new byte[4]; + int read = FileSystem.RandomAccess.Read(handle, buffer, 3); + + await That(read).IsEqualTo(0); + } + + [Test] + [AutoArguments] + public async Task Read_WithEmptyBuffer_ShouldReturnZero(string path) + { + byte[] contents = [1, 2, 3,]; + FileSystem.File.WriteAllBytes(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + int read = FileSystem.RandomAccess.Read(handle, Span.Empty, 0); + + await That(read).IsEqualTo(0); + } + + [Test] + [AutoArguments] + public async Task Read_WithNegativeOffset_ShouldThrowArgumentOutOfRangeException(string path) + { + FileSystem.File.WriteAllBytes(path, [1, 2, 3,]); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + void Act() => FileSystem.RandomAccess.Read(handle, new byte[2], -1); + + await That(Act).Throws(); + } + + [Test] + [AutoArguments] + public async Task Read_WithWriteOnlyHandle_ShouldThrowUnauthorizedAccessException(string path) + { + FileSystem.File.WriteAllBytes(path, [1, 2, 3,]); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write); + + void Act() => FileSystem.RandomAccess.Read(handle, new byte[2], 0); + + await That(Act).Throws(); + } + + [Test] + [AutoArguments] + public async Task Read_WithScatterBuffers_ShouldFillThemInOrder(string path) + { + byte[] contents = [1, 2, 3, 4, 5, 6,]; + FileSystem.File.WriteAllBytes(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + Memory first = new byte[2]; + Memory second = new byte[3]; + IReadOnlyList> buffers = [first, second,]; + + long read = FileSystem.RandomAccess.Read(handle, buffers, 1); + + await That(read).IsEqualTo(5L); + await That(first.ToArray()).IsEqualTo(new byte[] { 2, 3, }); + await That(second.ToArray()).IsEqualTo(new byte[] { 4, 5, 6, }); + } + + [Test] + [AutoArguments] + public async Task Read_WithScatterBuffers_WhenFileIsShorter_ShouldReturnShortRead(string path) + { + byte[] contents = [1, 2, 3,]; + FileSystem.File.WriteAllBytes(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + Memory first = new byte[2]; + Memory second = new byte[4]; + IReadOnlyList> buffers = [first, second,]; + + long read = FileSystem.RandomAccess.Read(handle, buffers, 0); + + await That(read).IsEqualTo(3L); + await That(first.ToArray()).IsEqualTo(new byte[] { 1, 2, }); + await That(second.ToArray()[0]).IsEqualTo((byte)3); + } + + [Test] + [AutoArguments] + public async Task ReadAsync_ShouldReadFromTheGivenOffset(string path) + { + byte[] contents = [1, 2, 3, 4, 5,]; + FileSystem.File.WriteAllBytes(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + Memory buffer = new byte[3]; + int read = await FileSystem.RandomAccess.ReadAsync(handle, buffer, 2, CancellationToken); + + await That(read).IsEqualTo(3); + await That(buffer.ToArray()).IsEqualTo(new byte[] { 3, 4, 5, }); + } + + [Test] + [AutoArguments] + public async Task ReadAsync_WithScatterBuffers_ShouldFillThemInOrder(string path) + { + byte[] contents = [1, 2, 3, 4,]; + FileSystem.File.WriteAllBytes(path, contents); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + Memory first = new byte[2]; + Memory second = new byte[2]; + IReadOnlyList> buffers = [first, second,]; + + long read = await FileSystem.RandomAccess.ReadAsync(handle, buffers, 0, CancellationToken); + + await That(read).IsEqualTo(4L); + await That(first.ToArray()).IsEqualTo(new byte[] { 1, 2, }); + await That(second.ToArray()).IsEqualTo(new byte[] { 3, 4, }); + } +} +#endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/Tests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/Tests.cs new file mode 100644 index 000000000..1f43c8775 --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/Tests.cs @@ -0,0 +1,123 @@ +#if FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System.IO; + +namespace Testably.Abstractions.Tests.FileSystem.RandomAccess; + +[FileSystemTests] +public class Tests(FileSystemTestData testData) : FileSystemTestBase(testData) +{ + [Test] + public async Task RandomAccess_ShouldBeSetOnTheFileSystem() + { + await That(FileSystem.RandomAccess).IsNotNull(); + await That(FileSystem.RandomAccess.FileSystem).IsSameAs(FileSystem); + } + + [Test] + [AutoArguments] + public async Task Operations_OnDisposedHandle_ShouldThrowObjectDisposedException(string path) + { + FileSystem.File.WriteAllBytes(path, [1, 2, 3,]); + + SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.ReadWrite); + handle.Dispose(); + + void ReadAct() => FileSystem.RandomAccess.Read(handle, new byte[1], 0); + void WriteAct() => FileSystem.RandomAccess.Write(handle, new byte[] { 1, }, 0); + void LengthAct() => FileSystem.RandomAccess.GetLength(handle); + + await That(ReadAct).Throws(); + await That(WriteAct).Throws(); + await That(LengthAct).Throws(); + } + + [Test] + [AutoArguments] + public async Task ReadWrite_ShouldRoundTripTheContent(string path, byte[] contents) + { + Skip.If(contents.Length == 0); + FileSystem.File.WriteAllBytes(path, []); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.ReadWrite); + + FileSystem.RandomAccess.Write(handle, contents, 0); + + byte[] buffer = new byte[contents.Length]; + int read = FileSystem.RandomAccess.Read(handle, buffer, 0); + + await That(read).IsEqualTo(contents.Length); + await That(buffer).IsEqualTo(contents); + } + + [Test] + [AutoArguments] + public async Task Write_ShouldUpdateTheFileThroughTheAbstraction(string path, byte[] contents) + { + Skip.If(contents.Length == 0); + FileSystem.File.WriteAllBytes(path, []); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write)) + { + FileSystem.RandomAccess.Write(handle, contents, 0); + } + + await That(FileSystem.File.ReadAllBytes(path)).IsEqualTo(contents); + } + +#if FEATURE_RANDOMACCESS_FLUSHTODISK + [Test] + [AutoArguments] + public async Task FlushToDisk_ShouldNotChangeTheContent(string path, byte[] contents) + { + Skip.If(contents.Length == 0); + FileSystem.File.WriteAllBytes(path, []); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write)) + { + FileSystem.RandomAccess.Write(handle, contents, 0); + FileSystem.RandomAccess.FlushToDisk(handle); + } + + await That(FileSystem.File.ReadAllBytes(path)).IsEqualTo(contents); + } + + [Test] + [AutoArguments] + public async Task FlushToDisk_ShouldBeRepeatable(string path) + { + FileSystem.File.WriteAllBytes(path, []); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write); + + void Act() + { + FileSystem.RandomAccess.FlushToDisk(handle); + FileSystem.RandomAccess.FlushToDisk(handle); + } + + await That(Act).DoesNotThrow(); + } + + [Test] + [AutoArguments] + public async Task FlushToDisk_OnDisposedHandle_ShouldThrowObjectDisposedException(string path) + { + FileSystem.File.WriteAllBytes(path, []); + + SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write); + handle.Dispose(); + + void Act() => FileSystem.RandomAccess.FlushToDisk(handle); + + await That(Act).Throws(); + } +#endif +} +#endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/WriteTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/WriteTests.cs new file mode 100644 index 000000000..115d52c87 --- /dev/null +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/WriteTests.cs @@ -0,0 +1,195 @@ +#if FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System.Collections.Generic; +using System.IO; + +namespace Testably.Abstractions.Tests.FileSystem.RandomAccess; + +[FileSystemTests] +public class WriteTests(FileSystemTestData testData) : FileSystemTestBase(testData) +{ + [Test] + [AutoArguments] + public async Task Write_ShouldWriteAtTheGivenOffset(string path) + { + FileSystem.File.WriteAllBytes(path, [1, 2, 3, 4,]); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write)) + { + FileSystem.RandomAccess.Write(handle, new byte[] { 9, 9, }, 1); + } + + await That(FileSystem.File.ReadAllBytes(path)) + .IsEqualTo(new byte[] { 1, 9, 9, 4, }); + } + + [Test] + [AutoArguments] + public async Task Write_BeyondTheEnd_ShouldGrowTheFile(string path) + { + FileSystem.File.WriteAllBytes(path, [1, 2,]); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write)) + { + FileSystem.RandomAccess.Write(handle, new byte[] { 7, }, 4); + } + + await That(FileSystem.File.ReadAllBytes(path)) + .IsEqualTo(new byte[] { 1, 2, 0, 0, 7, }); + } + + [Test] + [AutoArguments] + public async Task Write_ShouldNotTruncateTheRemainder(string path) + { + FileSystem.File.WriteAllBytes(path, [1, 2, 3, 4, 5,]); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write)) + { + FileSystem.RandomAccess.Write(handle, new byte[] { 9, }, 1); + } + + await That(FileSystem.File.ReadAllBytes(path)) + .IsEqualTo(new byte[] { 1, 9, 3, 4, 5, }); + } + + [Test] + [AutoArguments] + public async Task Write_WithEmptyBuffer_ShouldNotChangeTheFile(string path) + { + FileSystem.File.WriteAllBytes(path, [1, 2, 3,]); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write)) + { + FileSystem.RandomAccess.Write(handle, ReadOnlySpan.Empty, 0); + } + + await That(FileSystem.File.ReadAllBytes(path)).IsEqualTo(new byte[] { 1, 2, 3, }); + } + + [Test] + [AutoArguments] + public async Task Write_WithNegativeOffset_ShouldThrowArgumentOutOfRangeException(string path) + { + FileSystem.File.WriteAllBytes(path, [1,]); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write); + + void Act() => FileSystem.RandomAccess.Write(handle, new byte[] { 1, }, -1); + + await That(Act).Throws(); + } + + [Test] + [AutoArguments] + public async Task Write_WithReadOnlyHandle_ShouldThrowUnauthorizedAccessException(string path) + { + FileSystem.File.WriteAllBytes(path, [1, 2, 3,]); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + void Act() => FileSystem.RandomAccess.Write(handle, new byte[] { 9, }, 0); + + await That(Act).Throws(); + } + + [Test] + [AutoArguments] + public async Task Write_WithGatherBuffers_ShouldWriteThemInOrder(string path) + { + FileSystem.File.WriteAllBytes(path, []); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write)) + { + ReadOnlyMemory first = new byte[] { 1, 2, }; + ReadOnlyMemory second = new byte[] { 3, }; + IReadOnlyList> buffers = [first, second,]; + + FileSystem.RandomAccess.Write(handle, buffers, 0); + } + + await That(FileSystem.File.ReadAllBytes(path)).IsEqualTo(new byte[] { 1, 2, 3, }); + } + + [Test] + [AutoArguments] + public async Task WriteAsync_ShouldWriteAtTheGivenOffset(string path) + { + FileSystem.File.WriteAllBytes(path, [1, 2, 3,]); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write)) + { + ReadOnlyMemory buffer = new byte[] { 8, 8, }; + await FileSystem.RandomAccess.WriteAsync(handle, buffer, 1, CancellationToken); + } + + await That(FileSystem.File.ReadAllBytes(path)).IsEqualTo(new byte[] { 1, 8, 8, }); + } + + [Test] + [AutoArguments] + public async Task WriteAsync_WithGatherBuffers_ShouldWriteThemInOrder(string path) + { + FileSystem.File.WriteAllBytes(path, []); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write)) + { + ReadOnlyMemory first = new byte[] { 4, }; + ReadOnlyMemory second = new byte[] { 5, 6, }; + IReadOnlyList> buffers = [first, second,]; + + await FileSystem.RandomAccess.WriteAsync(handle, buffers, 0, CancellationToken); + } + + await That(FileSystem.File.ReadAllBytes(path)).IsEqualTo(new byte[] { 4, 5, 6, }); + } + + [Test] + [AutoArguments] + public async Task Write_ShouldBeVisibleThroughAnotherHandle(string path) + { + FileSystem.File.WriteAllBytes(path, []); + + using SafeFileHandle writer = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write, FileShare.ReadWrite); + using SafeFileHandle reader = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + FileSystem.RandomAccess.Write(writer, new byte[] { 1, 2, 3, }, 0); + + byte[] buffer = new byte[3]; + int read = FileSystem.RandomAccess.Read(reader, buffer, 0); + + await That(read).IsEqualTo(3); + await That(buffer).IsEqualTo(new byte[] { 1, 2, 3, }); + } + + [Test] + [AutoArguments] + public async Task Write_ShouldBeVisibleThroughAStreamOnTheSamePath(string path) + { + FileSystem.File.WriteAllBytes(path, []); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write, FileShare.ReadWrite)) + { + FileSystem.RandomAccess.Write(handle, new byte[] { 1, 2, 3, }, 0); + } + + using FileSystemStream stream = FileSystem.File.OpenRead(path); + byte[] buffer = new byte[3]; + int read = stream.Read(buffer, 0, buffer.Length); + + await That(read).IsEqualTo(3); + await That(buffer).IsEqualTo(new byte[] { 1, 2, 3, }); + } +} +#endif diff --git a/Tests/Testably.Abstractions.Tests/Testably.Abstractions.Tests.csproj b/Tests/Testably.Abstractions.Tests/Testably.Abstractions.Tests.csproj index a31e9bc1d..0570b4185 100644 --- a/Tests/Testably.Abstractions.Tests/Testably.Abstractions.Tests.csproj +++ b/Tests/Testably.Abstractions.Tests/Testably.Abstractions.Tests.csproj @@ -1,7 +1,7 @@  - + From f6c29f3eb43d37cf85a16ba567630d28007b8d40 Mon Sep 17 00:00:00 2001 From: Mpdreamz Date: Sun, 20 Sep 2026 15:50:14 +0200 Subject: [PATCH 02/17] fix: address review findings on the random-access mock - Serialise read-modify-write sequences per file. `RandomAccess` permits concurrent writes at distinct offsets, and rebuilding the whole buffer without a gate let one of them discard the other. - Copy only what the destination asks for. Reading a single byte at the start of a large file materialised the entire tail first. - Follow the file rather than the path when applying `FileOptions.DeleteOnClose`, so a rename while the handle is open deletes the right file and leaves a replacement at the old path alone. Co-Authored-By: Claude Opus 5 --- .../FileSystem/MockSafeFileHandleRegistry.cs | 15 +- .../FileSystem/RandomAccessMock.cs | 138 +++++++++++------- .../Storage/IStorage.cs | 10 ++ .../Storage/InMemoryStorage.cs | 21 +++ .../RandomAccess/HandleIdentityTests.cs | 43 ++++++ .../FileSystem/RandomAccess/WriteTests.cs | 37 +++++ 6 files changed, 207 insertions(+), 57 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs index 1115aacb0..6660744b6 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs @@ -283,7 +283,14 @@ internal void ReleaseClosedHandles() } _pendingDeletes.RemoveAt(i); - _fileSystem.Storage.DeleteContainer(pending.Location, FileSystemTypes.File); + + // The file may have been renamed since the handle was opened, and a container survives a rename, so + // the deletion has to follow the container rather than the path it was opened at. + IStorageLocation? current = _fileSystem.Storage.GetLocation(pending.Container); + if (current is not null) + { + _fileSystem.Storage.DeleteContainer(current, FileSystemTypes.File); + } } } finally @@ -351,12 +358,8 @@ internal Entry(SafeFileHandle handle, internal SafeFileHandle Handle { get; } /// - /// The location the handle was opened on. + /// The location the handle was opened on, which a rename can invalidate. /// - /// - /// Only used to apply . A container does not expose its current - /// location, so a file renamed while such a handle is open is deleted under its original name. - /// internal IStorageLocation Location { get; } internal FileMode Mode { get; } diff --git a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs index 470acfb4f..d05578ed3 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs @@ -14,6 +14,9 @@ internal sealed class RandomAccessMock : IRandomAccess { private readonly MockFileSystem _fileSystem; + private readonly System.Runtime.CompilerServices.ConditionalWeakTable + _gates = new(); + internal RandomAccessMock(MockFileSystem fileSystem) { _fileSystem = fileSystem; @@ -54,10 +57,7 @@ public int Read(SafeFileHandle handle, Span buffer, long fileOffset) using IDisposable registration = _fileSystem.StatisticsRegistration .RandomAccess.RegisterMethod(nameof(Read), handle, fileOffset); - byte[] bytes = ReadBytes(handle, fileOffset); - int count = Math.Min(buffer.Length, bytes.Length); - bytes.AsSpan(0, count).CopyTo(buffer); - return count; + return ReadInto(handle, buffer, fileOffset); } /// @@ -71,8 +71,7 @@ public long Read(SafeFileHandle handle, IReadOnlyList> buffers, lon throw new ArgumentNullException(nameof(buffers)); } - byte[] bytes = ReadBytes(handle, fileOffset); - return Scatter(bytes, buffers); + return ReadInto(handle, buffers, fileOffset); } /// @@ -120,10 +119,13 @@ public void SetLength(SafeFileHandle handle, long length) } IStorageContainer container = GetContainer(handle, FileAccess.Write); - byte[] bytes = container.GetBytes(); - byte[] resized = new byte[length]; - Array.Copy(bytes, resized, Math.Min(bytes.Length, length)); - container.WriteBytes(resized); + lock (Gate(container)) + { + byte[] bytes = container.GetBytes(); + byte[] resized = new byte[length]; + Array.Copy(bytes, resized, Math.Min(bytes.Length, length)); + container.WriteBytes(resized); + } } #endif @@ -188,24 +190,6 @@ public ValueTask WriteAsync(SafeFileHandle handle, #endregion - private static long Scatter(byte[] bytes, IReadOnlyList> buffers) - { - int position = 0; - foreach (Memory buffer in buffers) - { - if (position >= bytes.Length) - { - break; - } - - int count = Math.Min(buffer.Length, bytes.Length - position); - bytes.AsSpan(position, count).CopyTo(buffer.Span); - position += count; - } - - return position; - } - private static byte[] Gather(IReadOnlyList> buffers) { int length = 0; @@ -253,26 +237,64 @@ private IStorageContainer GetContainer(SafeFileHandle handle, FileAccess? requir } /// - /// Returns the bytes of the file that refers to, starting at - /// ; an offset at or beyond the end yields an empty result, which is the short - /// read that reports as zero bytes. + /// Copies into from , returning the number of bytes + /// copied. An offset at or beyond the end copies nothing, which is the short read that + /// reports as zero bytes. /// - private byte[] ReadBytes(SafeFileHandle handle, long fileOffset) + private int ReadInto(SafeFileHandle handle, Span buffer, long fileOffset) { if (fileOffset < 0) { throw ExceptionFactory.NonNegativeNumberRequired("fileOffset"); } - byte[] bytes = GetContainer(handle, FileAccess.Read).GetBytes(); - if (fileOffset >= bytes.Length) + IStorageContainer container = GetContainer(handle, FileAccess.Read); + lock (Gate(container)) { - return []; + byte[] bytes = container.GetBytes(); + if (fileOffset >= bytes.Length) + { + return 0; + } + + int count = (int)Math.Min(buffer.Length, bytes.Length - fileOffset); + bytes.AsSpan((int)fileOffset, count).CopyTo(buffer); + return count; } + } - byte[] result = new byte[bytes.Length - fileOffset]; - Array.Copy(bytes, fileOffset, result, 0, result.Length); - return result; + /// + /// Copies into in order from , returning the number + /// of bytes copied. + /// + private long ReadInto(SafeFileHandle handle, IReadOnlyList> buffers, + long fileOffset) + { + if (fileOffset < 0) + { + throw ExceptionFactory.NonNegativeNumberRequired("fileOffset"); + } + + IStorageContainer container = GetContainer(handle, FileAccess.Read); + lock (Gate(container)) + { + byte[] bytes = container.GetBytes(); + long copied = 0; + foreach (Memory buffer in buffers) + { + long position = fileOffset + copied; + if (position >= bytes.Length) + { + break; + } + + int count = (int)Math.Min(buffer.Length, bytes.Length - position); + bytes.AsSpan((int)position, count).CopyTo(buffer.Span); + copied += count; + } + + return copied; + } } /// @@ -297,22 +319,36 @@ private void WriteBytes(SafeFileHandle handle, byte[] buffer, long fileOffset) return; } - byte[] bytes = container.GetBytes(); - if (mode == FileMode.Append && _fileSystem.Execute.IsLinux) + // Reading, growing and publishing the contents has to be atomic with respect to other operations on the same + // file: `RandomAccess` permits concurrent writes at distinct offsets, and without this one of them would be + // lost when both start from the same snapshot. + lock (Gate(container)) { - fileOffset = bytes.Length; - } + byte[] bytes = container.GetBytes(); + if (mode == FileMode.Append && _fileSystem.Execute.IsLinux) + { + fileOffset = bytes.Length; + } - long required = fileOffset + buffer.Length; - if (required > bytes.Length) - { - byte[] grown = new byte[required]; - Array.Copy(bytes, grown, bytes.Length); - bytes = grown; - } + long required = fileOffset + buffer.Length; + if (required > bytes.Length) + { + byte[] grown = new byte[required]; + Array.Copy(bytes, grown, bytes.Length); + bytes = grown; + } - Array.Copy(buffer, 0, bytes, fileOffset, buffer.Length); - container.WriteBytes(bytes); + Array.Copy(buffer, 0, bytes, fileOffset, buffer.Length); + container.WriteBytes(bytes); + } } + + /// + /// The object that serialises read-modify-write sequences on a file, so that concurrent operations through + /// different handles to the same file cannot lose each other's writes. + /// + private object Gate(IStorageContainer container) + => _gates.GetOrCreateValue(container); + } #endif diff --git a/Source/Testably.Abstractions.Testing/Storage/IStorage.cs b/Source/Testably.Abstractions.Testing/Storage/IStorage.cs index f1f6fc19e..9aefef67d 100644 --- a/Source/Testably.Abstractions.Testing/Storage/IStorage.cs +++ b/Source/Testably.Abstractions.Testing/Storage/IStorage.cs @@ -112,6 +112,16 @@ IEnumerable EnumerateLocations( [return: NotNullIfNotNull("path")] IStorageLocation? GetLocation(string? path, string? friendlyName = null); + /// + /// Returns the location that is currently registered under, or + /// if it is no longer registered. + /// + /// + /// A container survives a rename, so anything holding on to one — an open handle, for instance — cannot rely + /// on the location it was opened at. + /// + IStorageLocation? GetLocation(IStorageContainer container); + /// /// Returns the drives that are present. /// diff --git a/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs b/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs index ffcc9ba1b..a441ae0b7 100644 --- a/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs +++ b/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs @@ -769,6 +769,27 @@ internal IReadOnlyList GetContainers() .Select(x => x.Value) .ToList(); + /// + /// Returns the location that is currently registered under, or + /// if it is no longer registered. + /// + /// + /// A container survives a rename, so anything holding on to one — an open handle, for instance — cannot rely + /// on the location it was opened at. + /// + public IStorageLocation? GetLocation(IStorageContainer container) + { + foreach (KeyValuePair item in _containers) + { + if (ReferenceEquals(item.Value, container)) + { + return item.Key; + } + } + + return null; + } + /// /// Removes the drive with the given . /// diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs index 7e52d12ba..2d84f7c73 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs @@ -78,5 +78,48 @@ await That(FileSystem.File.Exists(path)).IsTrue() await That(FileSystem.File.Exists(path)).IsFalse(); } + + [Test] + [AutoArguments] + public async Task DeleteOnClose_ShouldFollowTheFile_WhenItIsRenamed( + string path, string other) + { + Skip.If(Test.RunsOnWindows, + "the mock tracks file share locks by path, so it refuses to rename or delete a file that a handle is open on, even when the handle permits delete sharing"); + + FileSystem.File.WriteAllText(path, null); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete, + FileOptions.DeleteOnClose)) + { + FileSystem.File.Move(path, other); + } + + await That(FileSystem.File.Exists(other)).IsFalse() + .Because("the deletion follows the file, not the path it was opened at"); + } + + [Test] + [AutoArguments] + public async Task DeleteOnClose_ShouldNotDeleteAReplacementAtTheOldPath( + string path, string other) + { + Skip.If(Test.RunsOnWindows, + "the mock tracks file share locks by path, so it refuses to rename or delete a file that a handle is open on, even when the handle permits delete sharing"); + + FileSystem.File.WriteAllText(path, null); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete, + FileOptions.DeleteOnClose)) + { + FileSystem.File.Move(path, other); + FileSystem.File.WriteAllText(path, "a different file"); + } + + await That(FileSystem.File.Exists(path)).IsTrue() + .Because("the replacement at the old path is a different file"); + } } #endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/WriteTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/WriteTests.cs index 115d52c87..1d89926bc 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/WriteTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/WriteTests.cs @@ -191,5 +191,42 @@ public async Task Write_ShouldBeVisibleThroughAStreamOnTheSamePath(string path) await That(read).IsEqualTo(3); await That(buffer).IsEqualTo(new byte[] { 1, 2, 3, }); } + + /// + /// permits concurrent writes through the same handle at distinct offsets, so a + /// read-modify-write of the whole file must not let one of them overwrite the other. + /// + [Test] + [AutoArguments] + public async Task Write_ConcurrentlyAtDistinctOffsets_ShouldKeepBothWrites(string path) + { + const int count = 250; + FileSystem.File.WriteAllBytes(path, new byte[2 * count]); + + using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) + { + await Task.WhenAll( + Task.Run(() => + { + for (int i = 0; i < count; i++) + { + FileSystem.RandomAccess.Write(handle, new byte[] { 1, }, i); + } + }, CancellationToken), + Task.Run(() => + { + for (int i = 0; i < count; i++) + { + FileSystem.RandomAccess.Write(handle, new byte[] { 2, }, count + i); + } + }, CancellationToken)); + } + + byte[] result = FileSystem.File.ReadAllBytes(path); + + await That(result[..count]).All().AreEqualTo((byte)1); + await That(result[count..]).All().AreEqualTo((byte)2); + } } #endif From e6ad1cc3ad80e53b7b599bf3278a6e354ff35b87 Mon Sep 17 00:00:00 2001 From: Mpdreamz Date: Sun, 20 Sep 2026 15:52:01 +0200 Subject: [PATCH 03/17] fix: hold handles from elsewhere to the same rules A handle the `MockFileSystem` did not create is resolved by the registered `ISafeFileHandleStrategy`, but was otherwise unchecked: a disposed or null handle still reached the strategy, where the real implementation throws. Also name the file in the exception when the mapped path no longer exists, instead of reporting an empty path. Co-Authored-By: Claude Opus 5 --- .../FileSystem/MockSafeFileHandleRegistry.cs | 13 +++- .../FileSystem/ForeignSafeFileHandleTests.cs | 70 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 Tests/Testably.Abstractions.Testing.Tests/FileSystem/ForeignSafeFileHandleTests.cs diff --git a/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs index 6660744b6..7be5e74d9 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs @@ -199,18 +199,29 @@ internal SafeFileHandleMock Map(SafeFileHandle handle) internal (IStorageContainer Container, FileAccess Access, FileMode Mode) GetContainer( SafeFileHandle handle) { + if (handle is null) + { + throw new ArgumentNullException(nameof(handle)); + } + if (Resolve(handle) is { } entry) { return (entry.Container, entry.Access, entry.Mode); } + // A handle from elsewhere is resolved by the strategy, but a disposed one is unusable whatever its origin. + if (handle.IsClosed) + { + throw ExceptionFactory.HandleIsClosed(); + } + SafeFileHandleMock mock = _fileSystem.SafeFileHandleStrategy.MapSafeFileHandle(handle); IStorageContainer container = _fileSystem.Storage .GetContainer(_fileSystem.Storage.GetLocation(mock.Path) .ThrowExceptionIfNotFound(_fileSystem)); if (container is NullContainer) { - throw ExceptionFactory.FileNotFound(""); + throw ExceptionFactory.FileNotFound(mock.Path); } return (container, FileAccess.ReadWrite, mock.Mode); diff --git a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ForeignSafeFileHandleTests.cs b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ForeignSafeFileHandleTests.cs new file mode 100644 index 000000000..abedba55d --- /dev/null +++ b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ForeignSafeFileHandleTests.cs @@ -0,0 +1,70 @@ +#if FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System.IO; +using Testably.Abstractions.Testing.FileSystem; + +namespace Testably.Abstractions.Testing.Tests.FileSystem; + +/// +/// Handles that the did not create are resolved by the registered +/// , but are otherwise held to the same rules. +/// +public class ForeignSafeFileHandleTests +{ + private static MockFileSystem Arrange(string path) + { + MockFileSystem fileSystem = new(); + fileSystem.File.WriteAllText(path, "some content"); + fileSystem.WithSafeFileHandleStrategy( + new DefaultSafeFileHandleStrategy(_ => new SafeFileHandleMock(path))); + return fileSystem; + } + + [Test] + public async Task Operations_OnDisposedForeignHandle_ShouldThrowObjectDisposedException() + { + MockFileSystem fileSystem = Arrange("file.txt"); + SafeFileHandle handle = new(new IntPtr(0x1234), ownsHandle: false); + handle.Dispose(); + + void Act() => fileSystem.RandomAccess.GetLength(handle); + + await That(Act).Throws(); + } + + [Test] + public async Task Operations_OnNullHandle_ShouldThrowArgumentNullException() + { + MockFileSystem fileSystem = Arrange("file.txt"); + + void Act() => fileSystem.File.GetAttributes((SafeFileHandle)null!); + + await That(Act).Throws(); + } + + [Test] + public async Task Operations_WhenMappedFileIsMissing_ShouldNameThePath() + { + MockFileSystem fileSystem = new(); + fileSystem.Initialize().WithSubdirectory("sub"); + fileSystem.WithSafeFileHandleStrategy( + new DefaultSafeFileHandleStrategy(_ => + new SafeFileHandleMock(fileSystem.Path.Combine("sub", "missing.txt")))); + using SafeFileHandle handle = new(new IntPtr(0x1234), ownsHandle: false); + + void Act() => fileSystem.File.GetAttributes(handle); + + await That(Act).Throws() + .WithMessage("*missing.txt*").AsWildcard(); + } + + [Test] + public async Task ForeignHandle_ShouldStillResolveThroughTheStrategy() + { + MockFileSystem fileSystem = Arrange("file.txt"); + using SafeFileHandle handle = new(new IntPtr(0x1234), ownsHandle: false); + + await That(fileSystem.RandomAccess.GetLength(handle)).IsEqualTo(12L); + } +} +#endif From 27b2626ea49d7cbf2d80ed5103b1a3782c98f441 Mon Sep 17 00:00:00 2001 From: Mpdreamz Date: Sun, 20 Sep 2026 16:19:27 +0200 Subject: [PATCH 04/17] fix: gate the mock's `SetLength` on the same flag as the interface Follows the interface change: one flag now limits the random-access surface that needs more than .NET 6. Co-Authored-By: Claude Opus 5 --- .../FileSystem/RandomAccessMock.cs | 2 +- .../FileSystem/RandomAccess/LengthTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs index d05578ed3..f82d9a704 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs @@ -106,7 +106,7 @@ public ValueTask ReadAsync(SafeFileHandle handle, IReadOnlyList(Read(handle, buffers, fileOffset)); } -#if FEATURE_FILESYSTEM_NET_7_OR_GREATER +#if FEATURE_RANDOMACCESS_FLUSHTODISK /// public void SetLength(SafeFileHandle handle, long length) { diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/LengthTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/LengthTests.cs index 39337c9e2..d5479ba5f 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/LengthTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/LengthTests.cs @@ -56,7 +56,7 @@ public async Task GetLength_AfterWriting_ShouldReflectTheNewLength(string path) await That(FileSystem.RandomAccess.GetLength(handle)).IsEqualTo(3L); } -#if FEATURE_FILESYSTEM_NET_7_OR_GREATER +#if FEATURE_RANDOMACCESS_FLUSHTODISK [Test] [AutoArguments] public async Task SetLength_ShouldTruncateTheFile(string path) From 40d6e6f3a8dbf3a53e8303502eaa29a2ae582e90 Mon Sep 17 00:00:00 2001 From: Mpdreamz Date: Sun, 20 Sep 2026 19:50:29 +0200 Subject: [PATCH 05/17] chore: consume the released interface and restore the build scope Rebased onto `main` now that the interface ships in 10.4.0-pre.1: - the project references added while the interface was unreleased go back to package references, - `Directory.Packages.props` moves to `[10.4.0-pre.1,10.5.0)`, - `BuildScope` returns to `Default`. Two of the tests that skipped the Windows-simulating variant now say why more precisely: moving a file held open by a handle needs `ignoreFileShare`, which is inert on Windows (#1086), and two openers that both permit `FileShare.ReadWrite | FileShare.Delete` are refused (#1090). Co-Authored-By: Claude Opus 5 --- Directory.Packages.props | 4 ++-- Pipeline/Build.cs | 2 +- .../Testably.Abstractions.Testing.csproj | 4 +--- .../Testably.Abstractions.TestHelpers.csproj | 4 ++-- .../Testably.Abstractions.Testing.Tests.csproj | 2 +- .../FileSystem/RandomAccess/HandleIdentityTests.cs | 10 +++++----- .../Testably.Abstractions.Tests.csproj | 2 +- 7 files changed, 13 insertions(+), 15 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 62cd8621f..704949961 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -55,8 +55,8 @@ - - + + diff --git a/Pipeline/Build.cs b/Pipeline/Build.cs index 21b3f6d5c..b3dc8a6eb 100644 --- a/Pipeline/Build.cs +++ b/Pipeline/Build.cs @@ -20,7 +20,7 @@ partial class Build : FalloutBuild /// /// Afterward, you can update the package reference in `Directory.Packages.props` and reset this flag. /// - readonly BuildScope BuildScope = BuildScope.CoreOnly; + readonly BuildScope BuildScope = BuildScope.Default; [Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release; diff --git a/Source/Testably.Abstractions.Testing/Testably.Abstractions.Testing.csproj b/Source/Testably.Abstractions.Testing/Testably.Abstractions.Testing.csproj index 5d8622bd4..592577a8d 100644 --- a/Source/Testably.Abstractions.Testing/Testably.Abstractions.Testing.csproj +++ b/Source/Testably.Abstractions.Testing/Testably.Abstractions.Testing.csproj @@ -10,9 +10,7 @@ - - + diff --git a/Tests/Helpers/Testably.Abstractions.TestHelpers/Testably.Abstractions.TestHelpers.csproj b/Tests/Helpers/Testably.Abstractions.TestHelpers/Testably.Abstractions.TestHelpers.csproj index cc360f959..e7ebccc51 100644 --- a/Tests/Helpers/Testably.Abstractions.TestHelpers/Testably.Abstractions.TestHelpers.csproj +++ b/Tests/Helpers/Testably.Abstractions.TestHelpers/Testably.Abstractions.TestHelpers.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/Tests/Testably.Abstractions.Testing.Tests/Testably.Abstractions.Testing.Tests.csproj b/Tests/Testably.Abstractions.Testing.Tests/Testably.Abstractions.Testing.Tests.csproj index 6f14aca89..d4c4b2970 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/Testably.Abstractions.Testing.Tests.csproj +++ b/Tests/Testably.Abstractions.Testing.Tests/Testably.Abstractions.Testing.Tests.csproj @@ -13,7 +13,7 @@ - + diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs index 2d84f7c73..17a5e6b1c 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs @@ -15,7 +15,7 @@ public class HandleIdentityTests(FileSystemTestData testData) : FileSystemTestBa public async Task Handle_ShouldKeepWorking_WhenTheFileIsRenamed(string path, string other) { Skip.If(Test.RunsOnWindows, - "the mock tracks file share locks by path, so it refuses to rename or delete a file that a handle is open on, even when the handle permits delete sharing"); + "moving a file that a handle holds open needs `ignoreFileShare`, which is inert on Windows: see #1086"); FileSystem.File.WriteAllBytes(path, [1, 2, 3, 4,]); @@ -37,7 +37,7 @@ public async Task Handle_ShouldNotFollowTheName_WhenAnotherFileTakesTheOldPath( string path, string other) { Skip.If(Test.RunsOnWindows, - "the mock tracks file share locks by path, so it refuses to rename or delete a file that a handle is open on, even when the handle permits delete sharing"); + "moving a file that a handle holds open needs `ignoreFileShare`, which is inert on Windows: see #1086"); FileSystem.File.WriteAllBytes(path, [1, 2, 3, 4,]); @@ -59,7 +59,7 @@ await That(buffer).IsEqualTo(new byte[] { 1, 2, 3, 4, }) public async Task DeleteOnClose_ShouldDeleteOnlyWhenTheLastHandleIsClosed(string path) { Skip.If(Test.RunsOnWindows, - "the mock tracks file share locks by path, so it refuses to rename or delete a file that a handle is open on, even when the handle permits delete sharing"); + "two openers that both permit `FileShare.ReadWrite | FileShare.Delete` are refused: see #1090"); FileSystem.File.WriteAllText(path, null); @@ -85,7 +85,7 @@ public async Task DeleteOnClose_ShouldFollowTheFile_WhenItIsRenamed( string path, string other) { Skip.If(Test.RunsOnWindows, - "the mock tracks file share locks by path, so it refuses to rename or delete a file that a handle is open on, even when the handle permits delete sharing"); + "moving a file that a handle holds open needs `ignoreFileShare`, which is inert on Windows: see #1086"); FileSystem.File.WriteAllText(path, null); @@ -106,7 +106,7 @@ public async Task DeleteOnClose_ShouldNotDeleteAReplacementAtTheOldPath( string path, string other) { Skip.If(Test.RunsOnWindows, - "the mock tracks file share locks by path, so it refuses to rename or delete a file that a handle is open on, even when the handle permits delete sharing"); + "moving a file that a handle holds open needs `ignoreFileShare`, which is inert on Windows: see #1086"); FileSystem.File.WriteAllText(path, null); diff --git a/Tests/Testably.Abstractions.Tests/Testably.Abstractions.Tests.csproj b/Tests/Testably.Abstractions.Tests/Testably.Abstractions.Tests.csproj index 0570b4185..946e20949 100644 --- a/Tests/Testably.Abstractions.Tests/Testably.Abstractions.Tests.csproj +++ b/Tests/Testably.Abstractions.Tests/Testably.Abstractions.Tests.csproj @@ -1,7 +1,7 @@  - + From 81eb0c6dc8bbb642cfff04c15c2c7c866b5d40e8 Mon Sep 17 00:00:00 2001 From: Mpdreamz Date: Sun, 20 Sep 2026 20:07:45 +0200 Subject: [PATCH 06/17] chore: keep only the comments that explain a non-obvious why Drops the summaries on private and internal members that restate the member name, the duplicated rationale on `IStorage.GetLocation` in favour of ``, and the prose repeated at both ends of the handle sweep. What stays is the handful of decisions the code cannot show on its own: why synthetic handle values start where they do, why `deleteAccess` is not what `FileOptions.DeleteOnClose` means, why a closed handle is noticed late, why writes are serialised, and the Linux `pwrite(2)` deviation for append handles. Co-Authored-By: Claude Opus 5 --- .../FileSystem/FileStreamFactoryMock.cs | 5 -- .../FileSystem/MockSafeFileHandleRegistry.cs | 46 ++----------------- .../FileSystem/RandomAccessMock.cs | 33 +++---------- .../Helpers/FileModeHelper.cs | 4 -- .../MockFileSystem.cs | 8 +--- .../Storage/IStorage.cs | 7 +-- .../Storage/InMemoryStorage.cs | 9 +--- .../FileSystem/ForeignSafeFileHandleTests.cs | 4 -- .../FileSystem/File/SafeFileHandleTests.cs | 6 --- .../OpenHandleStreamTests.cs | 11 ----- .../FileSystem/RandomAccess/AppendTests.cs | 6 +-- .../RandomAccess/HandleIdentityTests.cs | 3 -- .../FileSystem/RandomAccess/WriteTests.cs | 4 -- 13 files changed, 18 insertions(+), 128 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileStreamFactoryMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileStreamFactoryMock.cs index 31d18875f..ce1ed9c2e 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileStreamFactoryMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileStreamFactoryMock.cs @@ -232,11 +232,6 @@ public FileSystemStream Wrap(FileStream fileStream) #endregion - /// - /// Resolves the file behind , preferring a handle that the - /// created itself and falling back to the registered - /// . - /// private SafeFileHandleMock MapSafeFileHandle(SafeFileHandle handle) #if FEATURE_FILESYSTEM_RANDOMACCESS => _fileSystem.SafeFileHandleRegistry.Map(handle); diff --git a/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs index 7be5e74d9..b8d22eb92 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs @@ -34,8 +34,7 @@ internal sealed class MockSafeFileHandleRegistry private readonly object _lock = new(); #endif /// - /// Files whose last handle has been closed while other handles were - /// still open on them. They are deleted once the last of those closes. + /// Deletions waiting for the last handle to the file to close. /// private readonly List _pendingDeletes = []; @@ -48,10 +47,6 @@ internal MockSafeFileHandleRegistry(MockFileSystem fileSystem) _fileSystem = fileSystem; } - /// - /// Opens and returns a that this registry can resolve - /// back to the file. - /// internal SafeFileHandle Open(string path, FileMode mode, FileAccess access, @@ -66,7 +61,6 @@ internal SafeFileHandle Open(string path, FileModeHelper.ThrowIfInvalidModeAccess(mode, access); - // Release the share locks of handles that were closed since the last access, before requesting a new one. ReleaseClosedHandles(); IStorageLocation location = _fileSystem.Storage @@ -133,10 +127,6 @@ internal SafeFileHandle Open(string path, } } - /// - /// Resolves a that this registry created, or returns if the - /// handle originated elsewhere and has to be mapped by the instead. - /// internal Entry? Resolve(SafeFileHandle handle) { ReleaseClosedHandles(); @@ -165,10 +155,6 @@ internal SafeFileHandle Open(string path, } } - /// - /// Indicates whether was created by this registry, irrespective of whether it is - /// still open. - /// internal bool IsKnown(SafeFileHandle handle) { lock (_lock) @@ -178,24 +164,15 @@ internal bool IsKnown(SafeFileHandle handle) } } - /// - /// Maps to the file it refers to, either because this registry created it or, - /// failing that, by asking the registered . - /// internal SafeFileHandleMock Map(SafeFileHandle handle) => Resolve(handle)?.Mock ?? _fileSystem.SafeFileHandleStrategy.MapSafeFileHandle(handle); /// - /// Returns the file that refers to, together with the and - /// the handle was opened with. + /// A handle refers to the file that was opened, not to its name, so one this registry created resolves to the + /// container it was opened on. Only a handle from elsewhere, which carries nothing but a path, is looked up by + /// name. /// - /// - /// A handle refers to the file that was opened, not to its name: it keeps working when the file is renamed, and - /// does not start referring to whatever is later created under the original path. A handle created by this - /// registry is therefore resolved to the container it was opened on, and only a handle that originated - /// elsewhere — which carries nothing but a path, and no access information — is looked up by name. - /// internal (IStorageContainer Container, FileAccess Access, FileMode Mode) GetContainer( SafeFileHandle handle) { @@ -209,7 +186,6 @@ internal SafeFileHandleMock Map(SafeFileHandle handle) return (entry.Container, entry.Access, entry.Mode); } - // A handle from elsewhere is resolved by the strategy, but a disposed one is unusable whatever its origin. if (handle.IsClosed) { throw ExceptionFactory.HandleIsClosed(); @@ -228,11 +204,8 @@ internal SafeFileHandleMock Map(SafeFileHandle handle) } /// - /// Releases the file share locks held by handles that the caller has since disposed, and applies - /// for them. - /// /// is sealed, so the mock cannot be notified when one is closed and instead - /// notices on the next registry access. + /// notices here, on the next registry access. /// internal void ReleaseClosedHandles() { @@ -330,18 +303,12 @@ private bool IsStillOpen(IStorageContainer container) } } - /// - /// Indicates whether lies in the range of handle values this registry has issued. - /// private bool WasIssued(IntPtr value) { long candidate = value.ToInt64(); return candidate >= FirstHandleValue && candidate < _nextHandleValue; } - /// - /// The file behind a created by the . - /// internal sealed class Entry { internal Entry(SafeFileHandle handle, @@ -368,9 +335,6 @@ internal Entry(SafeFileHandle handle, internal IStorageContainer Container { get; } internal SafeFileHandle Handle { get; } - /// - /// The location the handle was opened on, which a rename can invalidate. - /// internal IStorageLocation Location { get; } internal FileMode Mode { get; } diff --git a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs index f82d9a704..4b269e4a6 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs @@ -35,8 +35,8 @@ public void FlushToDisk(SafeFileHandle handle) using IDisposable registration = _fileSystem.StatisticsRegistration .RandomAccess.RegisterMethod(nameof(FlushToDisk), handle); - // The in-memory storage has no write-back cache, so there is nothing to flush. The call is still resolved - // and counted, so that a test can assert that a durability barrier was requested. + // Nothing to flush without a write-back cache, but the handle is still resolved and the call counted, so a + // test can assert that a durability barrier was requested. _ = GetContainer(handle, FileAccess.Write); } #endif @@ -236,11 +236,6 @@ private IStorageContainer GetContainer(SafeFileHandle handle, FileAccess? requir return (container, mode); } - /// - /// Copies into from , returning the number of bytes - /// copied. An offset at or beyond the end copies nothing, which is the short read that - /// reports as zero bytes. - /// private int ReadInto(SafeFileHandle handle, Span buffer, long fileOffset) { if (fileOffset < 0) @@ -263,10 +258,6 @@ private int ReadInto(SafeFileHandle handle, Span buffer, long fileOffset) } } - /// - /// Copies into in order from , returning the number - /// of bytes copied. - /// private long ReadInto(SafeFileHandle handle, IReadOnlyList> buffers, long fileOffset) { @@ -297,15 +288,6 @@ private long ReadInto(SafeFileHandle handle, IReadOnlyList> buffers } } - /// - /// Writes at , growing the file and zero-filling any - /// gap between the previous end and the offset. - /// - /// - /// On a handle opened with the offset is ignored on Linux, whose - /// pwrite(2) appends to the end of the file when the descriptor carries O_APPEND, contrary to - /// POSIX. Windows and macOS honour the offset. - /// private void WriteBytes(SafeFileHandle handle, byte[] buffer, long fileOffset) { if (fileOffset < 0) @@ -319,12 +301,13 @@ private void WriteBytes(SafeFileHandle handle, byte[] buffer, long fileOffset) return; } - // Reading, growing and publishing the contents has to be atomic with respect to other operations on the same - // file: `RandomAccess` permits concurrent writes at distinct offsets, and without this one of them would be - // lost when both start from the same snapshot. + // `RandomAccess` permits concurrent writes at distinct offsets, which would lose each other if two of them + // started from the same snapshot. lock (Gate(container)) { byte[] bytes = container.GetBytes(); + // Linux `pwrite(2)` appends when the descriptor carries `O_APPEND`, whatever offset is passed, contrary + // to POSIX; Windows and macOS honour the offset. if (mode == FileMode.Append && _fileSystem.Execute.IsLinux) { fileOffset = bytes.Length; @@ -343,10 +326,6 @@ private void WriteBytes(SafeFileHandle handle, byte[] buffer, long fileOffset) } } - /// - /// The object that serialises read-modify-write sequences on a file, so that concurrent operations through - /// different handles to the same file cannot lose each other's writes. - /// private object Gate(IStorageContainer container) => _gates.GetOrCreateValue(container); diff --git a/Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs b/Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs index 8f86084bd..0b02203e9 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs @@ -4,10 +4,6 @@ namespace Testably.Abstractions.Testing.Helpers; internal static class FileModeHelper { - /// - /// Validates that the and combination is allowed when - /// opening a file. - /// internal static void ThrowIfInvalidModeAccess(FileMode mode, FileAccess access) { if (mode == FileMode.Append) diff --git a/Source/Testably.Abstractions.Testing/MockFileSystem.cs b/Source/Testably.Abstractions.Testing/MockFileSystem.cs index 119a6c07b..56383106c 100644 --- a/Source/Testably.Abstractions.Testing/MockFileSystem.cs +++ b/Source/Testably.Abstractions.Testing/MockFileSystem.cs @@ -85,9 +85,6 @@ internal IAccessControlStrategy AccessControlStrategy internal FileSystemRegistration Registration { get; } #if FEATURE_FILESYSTEM_RANDOMACCESS - /// - /// Tracks the s that this created itself. - /// internal MockSafeFileHandleRegistry SafeFileHandleRegistry { get; } #endif @@ -107,8 +104,7 @@ internal IStorage Storage get { #if FEATURE_FILESYSTEM_RANDOMACCESS - // A `SafeFileHandle` is sealed, so the mock cannot be notified when one is closed. Noticing here means - // that any file system operation observes the released file share locks and `FileOptions.DeleteOnClose`. + // Sweeping here means every file system operation observes handles the caller has since closed. SafeFileHandleRegistry.ReleaseClosedHandles(); #endif return _storage; @@ -168,7 +164,7 @@ public MockFileSystem(Func options _pathMock = new PathMock(this); _storage = new InMemoryStorage(this); #if FEATURE_FILESYSTEM_RANDOMACCESS - // Created before anything can access `Storage`, which sweeps the registry for closed handles. + // Created before anything can reach `Storage`, which sweeps it. SafeFileHandleRegistry = new MockSafeFileHandleRegistry(this); #endif ChangeHandler = new ChangeHandler(this, initialization.RecordNotificationHistory); diff --git a/Source/Testably.Abstractions.Testing/Storage/IStorage.cs b/Source/Testably.Abstractions.Testing/Storage/IStorage.cs index 9aefef67d..ba6f1e28f 100644 --- a/Source/Testably.Abstractions.Testing/Storage/IStorage.cs +++ b/Source/Testably.Abstractions.Testing/Storage/IStorage.cs @@ -114,12 +114,9 @@ IEnumerable EnumerateLocations( /// /// Returns the location that is currently registered under, or - /// if it is no longer registered. + /// if it is no longer registered. A container survives a rename, so anything holding + /// on to one cannot rely on the location it was opened at. /// - /// - /// A container survives a rename, so anything holding on to one — an open handle, for instance — cannot rely - /// on the location it was opened at. - /// IStorageLocation? GetLocation(IStorageContainer container); /// diff --git a/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs b/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs index a441ae0b7..164aaafba 100644 --- a/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs +++ b/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs @@ -769,14 +769,7 @@ internal IReadOnlyList GetContainers() .Select(x => x.Value) .ToList(); - /// - /// Returns the location that is currently registered under, or - /// if it is no longer registered. - /// - /// - /// A container survives a rename, so anything holding on to one — an open handle, for instance — cannot rely - /// on the location it was opened at. - /// + /// public IStorageLocation? GetLocation(IStorageContainer container) { foreach (KeyValuePair item in _containers) diff --git a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ForeignSafeFileHandleTests.cs b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ForeignSafeFileHandleTests.cs index abedba55d..3e25c807c 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ForeignSafeFileHandleTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ForeignSafeFileHandleTests.cs @@ -5,10 +5,6 @@ namespace Testably.Abstractions.Testing.Tests.FileSystem; -/// -/// Handles that the did not create are resolved by the registered -/// , but are otherwise held to the same rules. -/// public class ForeignSafeFileHandleTests { private static MockFileSystem Arrange(string path) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs index a6e1fd580..c789965f8 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs @@ -4,12 +4,6 @@ namespace Testably.Abstractions.Tests.FileSystem.File; -/// -/// Covers every member that takes a . -/// -/// These could not be exercised against the before, because there was no way to -/// obtain a handle from the abstraction. -/// [FileSystemTests] public class SafeFileHandleTests(FileSystemTestData testData) : FileSystemTestBase(testData) { diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/OpenHandleStreamTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/OpenHandleStreamTests.cs index 539039767..2d7754460 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/OpenHandleStreamTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/OpenHandleStreamTests.cs @@ -4,17 +4,6 @@ namespace Testably.Abstractions.Tests.FileSystem.FileStreamFactory; -/// -/// Covers the overloads that take a , using a -/// handle obtained from the abstraction itself. -/// -/// The equivalent tests in SafeFileHandleTests are compiled out behind -/// EXECUTE_SAFEFILEHANDLE_TESTS, because creating a handle used to require platform invocation. -/// -/// The handles are opened with : a real adopts the -/// handle it is given, whereas re-opens the path behind it, so it needs the file -/// to be shareable with the handle that is already open on it. -/// [FileSystemTests] public class OpenHandleStreamTests(FileSystemTestData testData) : FileSystemTestBase(testData) { diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/AppendTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/AppendTests.cs index 6593a759d..90f9c8fc2 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/AppendTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/AppendTests.cs @@ -5,10 +5,8 @@ namespace Testably.Abstractions.Tests.FileSystem.RandomAccess; /// -/// Writing through a handle opened with . -/// -/// Linux deviates from POSIX here: its pwrite(2) appends to the end of the file when the descriptor -/// carries O_APPEND, whatever offset is passed. Windows and macOS honour the offset. +/// Linux `pwrite(2)` appends when the descriptor carries `O_APPEND`, whatever offset is passed, contrary to +/// POSIX; Windows and macOS honour the offset. /// [FileSystemTests] public class AppendTests(FileSystemTestData testData) : FileSystemTestBase(testData) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs index 17a5e6b1c..45978482a 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs @@ -4,9 +4,6 @@ namespace Testably.Abstractions.Tests.FileSystem.RandomAccess; -/// -/// A handle refers to the file that was opened, not to its name. -/// [FileSystemTests] public class HandleIdentityTests(FileSystemTestData testData) : FileSystemTestBase(testData) { diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/WriteTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/WriteTests.cs index 1d89926bc..90067e811 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/WriteTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/WriteTests.cs @@ -192,10 +192,6 @@ public async Task Write_ShouldBeVisibleThroughAStreamOnTheSamePath(string path) await That(buffer).IsEqualTo(new byte[] { 1, 2, 3, }); } - /// - /// permits concurrent writes through the same handle at distinct offsets, so a - /// read-modify-write of the whole file must not let one of them overwrite the other. - /// [Test] [AutoArguments] public async Task Write_ConcurrentlyAtDistinctOffsets_ShouldKeepBothWrites(string path) From 60a42f77f1796cedd3607e60b33ee6c3ad717685 Mon Sep 17 00:00:00 2001 From: Mpdreamz Date: Sun, 20 Sep 2026 21:47:15 +0200 Subject: [PATCH 07/17] fix: match the real file system's `DeleteOnClose` and resize behaviour Running in Release, which is what adds the real file system to the test matrix, showed two places where the mock had drifted from it on Unix. `FileOptions.DeleteOnClose` unlinks the name that was opened, as soon as that handle closes: it does not wait for other handles, a file renamed since then survives, and a replacement under the old name does not. The delete-on-last-close behaviour that follows the file across a rename is Windows', and is now gated on that. `RandomAccess.SetLength` on a read-only handle fails with `EINVAL` from `ftruncate`, reported as an `IOException`, where Windows denies access. Co-Authored-By: Claude Opus 5 --- .../FileSystem/MockSafeFileHandleRegistry.cs | 20 +++++++--- .../FileSystem/RandomAccessMock.cs | 40 ++++++++++++++----- .../Helpers/ExceptionFactory.cs | 3 ++ .../RandomAccess/HandleIdentityTests.cs | 33 ++++++--------- .../FileSystem/RandomAccess/LengthTests.cs | 19 ++++++++- 5 files changed, 78 insertions(+), 37 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs index b8d22eb92..2eec5e0ef 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs @@ -250,14 +250,25 @@ internal void ReleaseClosedHandles() foreach (Entry entry in released!) { entry.AccessLock.Dispose(); - if (entry.Options.HasFlag(FileOptions.DeleteOnClose)) + if (!entry.Options.HasFlag(FileOptions.DeleteOnClose)) + { + continue; + } + + if (_fileSystem.Execute.IsWindows) { _pendingDeletes.Add(entry); } + else + { + // Unix unlinks the name that was opened, as soon as this handle closes and whatever else still + // holds the file open — so a file renamed since then survives, and a replacement under the old + // name does not. + _fileSystem.Storage.DeleteContainer(entry.Location, FileSystemTypes.File); + } } - // The file is removed when the last handle to it is closed, not the first, so a deletion stays pending - // until no handle refers to the file any more. + // Windows removes the file once the last handle to it closes, and follows it across a rename. for (int i = _pendingDeletes.Count - 1; i >= 0; i--) { Entry pending = _pendingDeletes[i]; @@ -267,9 +278,6 @@ internal void ReleaseClosedHandles() } _pendingDeletes.RemoveAt(i); - - // The file may have been renamed since the handle was opened, and a container survives a rename, so - // the deletion has to follow the container rather than the path it was opened at. IStorageLocation? current = _fileSystem.Storage.GetLocation(pending.Container); if (current is not null) { diff --git a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs index 4b269e4a6..e8be9bd13 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs @@ -118,7 +118,7 @@ public void SetLength(SafeFileHandle handle, long length) throw ExceptionFactory.NonNegativeNumberRequired("length"); } - IStorageContainer container = GetContainer(handle, FileAccess.Write); + IStorageContainer container = GetContainerForResize(handle); lock (Gate(container)) { byte[] bytes = container.GetBytes(); @@ -209,11 +209,39 @@ private static byte[] Gather(IReadOnlyList> buffers) return bytes; } + private IStorageContainer GetContainerForResize(SafeFileHandle handle) + { + (IStorageContainer container, FileAccess access, _) = ResolveEntry(handle); + if (access.HasFlag(FileAccess.Write)) + { + return container; + } + + // `ftruncate` on a read-only descriptor fails with `EINVAL`, where Windows reports access denied. + throw _fileSystem.Execute.IsWindows + ? ExceptionFactory.AccessToPathDenied() + : ExceptionFactory.InvalidArgument( + _fileSystem.SafeFileHandleRegistry.Map(handle).Path); + } + private IStorageContainer GetContainer(SafeFileHandle handle, FileAccess? required) => Resolve(handle, required).Container; private (IStorageContainer Container, FileMode Mode) Resolve( SafeFileHandle handle, FileAccess? required) + { + (IStorageContainer container, FileAccess access, FileMode mode) = ResolveEntry(handle); + + if (required is { } requiredAccess && !access.HasFlag(requiredAccess)) + { + throw ExceptionFactory.AccessToPathDenied(); + } + + return (container, mode); + } + + private (IStorageContainer Container, FileAccess Access, FileMode Mode) ResolveEntry( + SafeFileHandle handle) { if (handle is null) { @@ -225,15 +253,7 @@ private IStorageContainer GetContainer(SafeFileHandle handle, FileAccess? requir throw ExceptionFactory.HandleIsInvalid(); } - (IStorageContainer container, FileAccess access, FileMode mode) = - _fileSystem.SafeFileHandleRegistry.GetContainer(handle); - - if (required is { } requiredAccess && !access.HasFlag(requiredAccess)) - { - throw ExceptionFactory.AccessToPathDenied(); - } - - return (container, mode); + return _fileSystem.SafeFileHandleRegistry.GetContainer(handle); } private int ReadInto(SafeFileHandle handle, Span buffer, long fileOffset) diff --git a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs index 95fc9d7d9..f46330f25 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs @@ -116,6 +116,9 @@ internal static InternalBufferOverflowException InternalBufferOverflowException( => new( $"The internal buffer is greater than the {internalBufferSize} allowed bytes (~ {messages} messages)."); + internal static IOException InvalidArgument(string path) + => new($"Invalid argument : '{path}'"); + internal static ArgumentException InvalidAccessCombination( FileMode mode, FileAccess access) => new($"Combining FileMode: {mode} with FileAccess: {access} is invalid.", diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs index 45978482a..ef6789e08 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs @@ -53,36 +53,30 @@ await That(buffer).IsEqualTo(new byte[] { 1, 2, 3, 4, }) [Test] [AutoArguments] - public async Task DeleteOnClose_ShouldDeleteOnlyWhenTheLastHandleIsClosed(string path) + public async Task DeleteOnClose_OnUnix_ShouldDeleteAsSoonAsThatHandleCloses(string path) { - Skip.If(Test.RunsOnWindows, - "two openers that both permit `FileShare.ReadWrite | FileShare.Delete` are refused: see #1090"); + Skip.If(Test.RunsOnWindows, "Windows deletes once the last handle closes"); FileSystem.File.WriteAllText(path, null); SafeFileHandle first = FileSystem.File.OpenHandle(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete, FileOptions.DeleteOnClose); - SafeFileHandle second = FileSystem.File.OpenHandle(path, + using SafeFileHandle second = FileSystem.File.OpenHandle(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete); first.Dispose(); - await That(FileSystem.File.Exists(path)).IsTrue() - .Because("a second handle is still open on the file"); - - second.Dispose(); - - await That(FileSystem.File.Exists(path)).IsFalse(); + await That(FileSystem.File.Exists(path)).IsFalse() + .Because("unlinking the name does not wait for other handles"); } [Test] [AutoArguments] - public async Task DeleteOnClose_ShouldFollowTheFile_WhenItIsRenamed( + public async Task DeleteOnClose_OnUnix_ShouldNotFollowTheFile_WhenItIsRenamed( string path, string other) { - Skip.If(Test.RunsOnWindows, - "moving a file that a handle holds open needs `ignoreFileShare`, which is inert on Windows: see #1086"); + Skip.If(Test.RunsOnWindows, "Windows follows the file across a rename"); FileSystem.File.WriteAllText(path, null); @@ -93,17 +87,16 @@ public async Task DeleteOnClose_ShouldFollowTheFile_WhenItIsRenamed( FileSystem.File.Move(path, other); } - await That(FileSystem.File.Exists(other)).IsFalse() - .Because("the deletion follows the file, not the path it was opened at"); + await That(FileSystem.File.Exists(other)).IsTrue() + .Because("the name that was opened is unlinked, not the file"); } [Test] [AutoArguments] - public async Task DeleteOnClose_ShouldNotDeleteAReplacementAtTheOldPath( + public async Task DeleteOnClose_OnUnix_ShouldDeleteAReplacementAtTheOldPath( string path, string other) { - Skip.If(Test.RunsOnWindows, - "moving a file that a handle holds open needs `ignoreFileShare`, which is inert on Windows: see #1086"); + Skip.If(Test.RunsOnWindows, "Windows follows the file across a rename"); FileSystem.File.WriteAllText(path, null); @@ -115,8 +108,8 @@ public async Task DeleteOnClose_ShouldNotDeleteAReplacementAtTheOldPath( FileSystem.File.WriteAllText(path, "a different file"); } - await That(FileSystem.File.Exists(path)).IsTrue() - .Because("the replacement at the old path is a different file"); + await That(FileSystem.File.Exists(path)).IsFalse() + .Because("the name is unlinked whatever now sits under it"); } } #endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/LengthTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/LengthTests.cs index d5479ba5f..94ca79e97 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/LengthTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/LengthTests.cs @@ -119,9 +119,11 @@ public async Task SetLength_WithNegativeLength_ShouldThrowArgumentOutOfRangeExce [Test] [AutoArguments] - public async Task SetLength_WithReadOnlyHandle_ShouldThrowUnauthorizedAccessException( + public async Task SetLength_WithReadOnlyHandle_OnWindows_ShouldThrowUnauthorizedAccess( string path) { + Skip.IfNot(Test.RunsOnWindows); + FileSystem.File.WriteAllBytes(path, [1, 2, 3,]); using SafeFileHandle handle = FileSystem.File.OpenHandle(path); @@ -130,6 +132,21 @@ public async Task SetLength_WithReadOnlyHandle_ShouldThrowUnauthorizedAccessExce await That(Act).Throws(); } + + [Test] + [AutoArguments] + public async Task SetLength_WithReadOnlyHandle_OnUnix_ShouldThrowIOException(string path) + { + Skip.If(Test.RunsOnWindows, "`ftruncate` reports an invalid argument, not access denied"); + + FileSystem.File.WriteAllBytes(path, [1, 2, 3,]); + + using SafeFileHandle handle = FileSystem.File.OpenHandle(path); + + void Act() => FileSystem.RandomAccess.SetLength(handle, 1); + + await That(Act).Throws(); + } #endif } #endif From 9360bd7a56dacf068d9d2164dc2bf112770886a8 Mon Sep 17 00:00:00 2001 From: Mpdreamz Date: Mon, 21 Sep 2026 07:59:01 +0200 Subject: [PATCH 08/17] fix: `RandomAccess.Write` honours the offset on an append handle I had the mock append on Linux, on the strength of the `pwrite(2)` note that `O_APPEND` overrides the offset. The Linux CI run disagrees: the offset is honoured there exactly as it is on Windows and macOS, so the special case and the platform-split tests are gone. Co-Authored-By: Claude Opus 5 --- .../FileSystem/RandomAccessMock.cs | 7 ------- .../FileSystem/RandomAccess/AppendTests.cs | 21 ++++++------------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs index e8be9bd13..cdb1fb77e 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs @@ -326,13 +326,6 @@ private void WriteBytes(SafeFileHandle handle, byte[] buffer, long fileOffset) lock (Gate(container)) { byte[] bytes = container.GetBytes(); - // Linux `pwrite(2)` appends when the descriptor carries `O_APPEND`, whatever offset is passed, contrary - // to POSIX; Windows and macOS honour the offset. - if (mode == FileMode.Append && _fileSystem.Execute.IsLinux) - { - fileOffset = bytes.Length; - } - long required = fileOffset + buffer.Length; if (required > bytes.Length) { diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/AppendTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/AppendTests.cs index 90f9c8fc2..53fd5feff 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/AppendTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/AppendTests.cs @@ -4,19 +4,13 @@ namespace Testably.Abstractions.Tests.FileSystem.RandomAccess; -/// -/// Linux `pwrite(2)` appends when the descriptor carries `O_APPEND`, whatever offset is passed, contrary to -/// POSIX; Windows and macOS honour the offset. -/// [FileSystemTests] public class AppendTests(FileSystemTestData testData) : FileSystemTestBase(testData) { [Test] [AutoArguments] - public async Task Write_OnAppendHandle_ShouldAppend_OnLinux(string path) + public async Task Write_OnAppendHandle_ShouldHonourTheOffset(string path) { - Skip.IfNot(Test.RunsOnLinux, "only Linux appends regardless of the offset"); - FileSystem.File.WriteAllBytes(path, [1, 2, 3, 4,]); using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, @@ -26,31 +20,28 @@ public async Task Write_OnAppendHandle_ShouldAppend_OnLinux(string path) } await That(FileSystem.File.ReadAllBytes(path)) - .IsEqualTo(new byte[] { 1, 2, 3, 4, 9, }); + .IsEqualTo(new byte[] { 9, 2, 3, 4, }); } [Test] [AutoArguments] - public async Task Write_OnAppendHandle_ShouldHonourTheOffset_OnWindowsAndMac(string path) + public async Task Write_OnAppendHandle_AtTheLength_ShouldExtendTheFile(string path) { - Skip.If(Test.RunsOnLinux, "Linux appends regardless of the offset"); - FileSystem.File.WriteAllBytes(path, [1, 2, 3, 4,]); using (SafeFileHandle handle = FileSystem.File.OpenHandle(path, FileMode.Append, FileAccess.Write)) { - FileSystem.RandomAccess.Write(handle, new byte[] { 9, }, 0); + FileSystem.RandomAccess.Write(handle, new byte[] { 9, }, 4); } await That(FileSystem.File.ReadAllBytes(path)) - .IsEqualTo(new byte[] { 9, 2, 3, 4, }); + .IsEqualTo(new byte[] { 1, 2, 3, 4, 9, }); } [Test] [AutoArguments] - public async Task Write_OnAppendHandle_ShouldAppendToAnEmptyFileRegardlessOfPlatform( - string path) + public async Task Write_OnAppendHandle_ToAnEmptyFile_ShouldWriteFromTheOffset(string path) { FileSystem.File.WriteAllBytes(path, []); From 65e1062229fd8e66d9b733578ffde1a9acb17804 Mon Sep 17 00:00:00 2001 From: Mpdreamz Date: Mon, 21 Sep 2026 09:04:11 +0200 Subject: [PATCH 09/17] docs: describe `RandomAccess` and handles from the abstraction - `safe-file-handles.mdx` opened by saying the mock has no handle to give you and sending you to `ISafeFileHandleStrategy`, which this change makes untrue. It now leads with `IFile.OpenHandle`, says what the handle carries and how `FileOptions.DeleteOnClose` differs between Unix and Windows, and keeps the strategy for handles from elsewhere. - The statistics table gains `RandomAccess`, which it introduces as one row per `IFileSystem` sub-property. - A new page for `RandomAccess` itself: positionless reads and writes, what each framework offers, the behaviour worth knowing, and why `FlushToDisk` is worth asserting against the mock. Co-Authored-By: Claude Opus 5 --- Docs/pages/docs/file-system/random-access.mdx | 69 +++++++++++++++++++ .../docs/file-system/safe-file-handles.mdx | 39 +++++++++-- Docs/pages/docs/file-system/statistics.mdx | 1 + 3 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 Docs/pages/docs/file-system/random-access.mdx diff --git a/Docs/pages/docs/file-system/random-access.mdx b/Docs/pages/docs/file-system/random-access.mdx new file mode 100644 index 000000000..c63380666 --- /dev/null +++ b/Docs/pages/docs/file-system/random-access.mdx @@ -0,0 +1,69 @@ +--- +sidebar_position: 12 +title: RandomAccess +--- + +# RandomAccess + +`IFileSystem.RandomAccess` abstracts the static methods on `System.IO.RandomAccess`. It reads and writes a file through a `SafeFileHandle` at an explicit offset, without a stream in between. + +Every method is positionless: the offset is a parameter, not state carried on the handle. Nothing advances, so two reads at the same offset return the same bytes and concurrent writes at different offsets do not interfere. + +```csharp +MockFileSystem fileSystem = new(); +fileSystem.File.WriteAllBytes("data.bin", [1, 2, 3, 4]); + +using SafeFileHandle handle = fileSystem.File.OpenHandle("data.bin", + FileMode.Open, FileAccess.ReadWrite); + +byte[] buffer = new byte[2]; +fileSystem.RandomAccess.Read(handle, buffer, fileOffset: 1); // buffer is [2, 3] + +fileSystem.RandomAccess.Write(handle, new byte[] { 9 }, fileOffset: 0); +await Expect.That(fileSystem.File.ReadAllBytes("data.bin")) + .IsEqualTo(new byte[] { 9, 2, 3, 4 }); +``` + +Get the handle from [`IFile.OpenHandle`](./safe-file-handles), so the file system it came from can resolve it. + +## What is available + +| Member | Available from | +|---|---| +| `GetLength` | .NET 6 | +| `Read`, `ReadAsync` | .NET 6 | +| `Write`, `WriteAsync` | .NET 6 | +| `SetLength` | .NET 8 | +| `FlushToDisk` | .NET 8 | + +`Read` and `Write` each have a scatter/gather overload taking an `IReadOnlyList>`, which fills or consumes the buffers in order as though they were one. + +## Behaviour worth knowing + +- A read whose offset is at or past the end of the file returns `0`; a read that runs off the end returns a short count rather than throwing. +- A write past the end grows the file and zero-fills the gap. +- `SetLength` truncates, or grows with zeroes. +- `GetLength` is metadata and works on a write-only handle. Reading a write-only handle, or writing a read-only one, throws `UnauthorizedAccessException`. +- On a handle opened with `FileMode.Append` the offset is still honoured — `RandomAccess` does not append for you. +- Operations on a disposed handle throw `ObjectDisposedException`. + +## `FlushToDisk` against the mock + +`FlushToDisk` asks the operating system to make prior writes durable. The `MockFileSystem` keeps its files in memory and has no write-back cache, so there is nothing to flush — but the call is still resolved against the handle and recorded, so a test can assert that a durability barrier was requested: + +```csharp +MockFileSystem fileSystem = new(); +fileSystem.File.WriteAllText("log", null); + +using (SafeFileHandle handle = fileSystem.File.OpenHandle("log", + FileMode.Open, FileAccess.Write)) +{ + fileSystem.RandomAccess.Write(handle, "entry"u8.ToArray(), 0); + fileSystem.RandomAccess.FlushToDisk(handle); +} + +await Expect.That(fileSystem.Statistics.RandomAccess.Methods) + .Contains(m => m.Name == nameof(IRandomAccess.FlushToDisk)); +``` + +That is something the real file system cannot tell you, and it is the reason to reach for the abstraction rather than the static class: code that needs a durability barrier can be tested for asking. diff --git a/Docs/pages/docs/file-system/safe-file-handles.mdx b/Docs/pages/docs/file-system/safe-file-handles.mdx index 98db10d8a..d49274a97 100644 --- a/Docs/pages/docs/file-system/safe-file-handles.mdx +++ b/Docs/pages/docs/file-system/safe-file-handles.mdx @@ -5,11 +5,38 @@ title: SafeFileHandle # SafeFileHandle -`SafeFileHandle` is a wrapper around an OS handle to a real file, and overloads like `File.GetLastAccessTime(SafeFileHandle)` route through it. Because the mock has no kernel handle, you have to tell it how to translate handles into mock paths. +`SafeFileHandle` is a wrapper around an OS handle to a real file, and overloads like `File.GetLastAccessTime(SafeFileHandle)` route through it. -`MockFileSystem.WithSafeFileHandleStrategy(ISafeFileHandleStrategy)` registers the translation. The strategy maps a `SafeFileHandle` to a `SafeFileHandleMock` that points at a location inside the mock. +## Handles from the abstraction -The default strategy (`NullSafeFileHandleStrategy`) is registered automatically and throws `NotSupportedException` for any handle - install a custom strategy as soon as your code under test reaches for `SafeFileHandle`. +`IFile.OpenHandle` returns a handle that the file system it came from understands, so nothing has to be registered: + +```csharp +MockFileSystem fileSystem = new(); +fileSystem.File.WriteAllText("file.txt", "some content"); + +using SafeFileHandle handle = fileSystem.File.OpenHandle("file.txt"); + +await Expect.That(fileSystem.File.GetLastAccessTime(handle)) + .IsEqualTo(fileSystem.File.GetLastAccessTime("file.txt")); +await Expect.That(fileSystem.RandomAccess.GetLength(handle)).IsEqualTo(12); +``` + +The handle refers to the file it was opened on rather than to its name, so reads and writes keep working after the file is renamed, and do not start addressing whatever is later created under the original path. + +It also carries the file share and the `FileOptions` it was opened with. `FileOptions.DeleteOnClose` follows the platform: on Unix the name that was opened is unlinked as soon as that handle closes, whatever else still holds the file open, so a file renamed in the meantime survives and a replacement under the old name does not; on Windows the file is removed once the last handle closes, and the deletion follows the file across a rename. + +Because `SafeFileHandle` is sealed, the mock cannot be notified when one is closed, so released share locks and `DeleteOnClose` take effect on the next file system operation rather than at the moment of disposal. + +A handle the `MockFileSystem` created is not a real OS handle, so passing it to `System.IO.RandomAccess` or to another process fails. Use `IFileSystem.RandomAccess` and the `IFile` overloads instead, which is the point of the abstraction. + +## Handles from elsewhere + +When a handle comes from somewhere the mock knows nothing about - platform invocation, or a library that hands you one - you still have to tell it which file that handle stands for. + +`MockFileSystem.WithSafeFileHandleStrategy(ISafeFileHandleStrategy)` registers the translation. The strategy maps a `SafeFileHandle` to a `SafeFileHandleMock` that points at a location inside the mock, and is consulted for any handle the mock did not create itself. + +The default strategy (`NullSafeFileHandleStrategy`) is registered automatically and throws `NotSupportedException` for any handle - install a custom strategy as soon as your code under test reaches for a handle from outside. ## A complete reference implementation @@ -78,8 +105,8 @@ realFileSystem.File.WriteAllText("real", "some content"); DateTime expected = new DateTime(2026, 1, 1); realFileSystem.File.SetLastAccessTime("real", expected); -// Open a SafeFileHandle on the real file via a small P/Invoke helper -SafeFileHandle handle = UnmanagedFileLoader.CreateSafeFileHandle("real"); +// Open a SafeFileHandle on the real file +SafeFileHandle handle = realFileSystem.File.OpenHandle("real"); // Register the mapping strategy.AddMapping(handle, "real", new SafeFileHandleMock("mock")); @@ -92,4 +119,4 @@ await Expect.That(mockFileSystem.File.GetLastAccessTime(handle)).IsEqualTo(expec await Expect.That(mockFileSystem.File.GetLastAccessTime("mock")).IsEqualTo(expected); ``` -`UnmanagedFileLoader` is a small P/Invoke helper that calls the Win32 `CreateFile` API to obtain a `SafeFileHandle` for an existing path. +Here the handle belongs to the *real* file system, so the mock cannot resolve it on its own and the strategy supplies the mapping. A handle from `mockFileSystem.File.OpenHandle` needs none of this. diff --git a/Docs/pages/docs/file-system/statistics.mdx b/Docs/pages/docs/file-system/statistics.mdx index 34a49c981..8cc22164d 100644 --- a/Docs/pages/docs/file-system/statistics.mdx +++ b/Docs/pages/docs/file-system/statistics.mdx @@ -33,6 +33,7 @@ await Expect.That(stats.File.Methods).Contains(m => m.Name == nameof(IFile.Write | `FileSystemWatcher` | `IPathStatistics` | | `FileVersionInfo` | `IPathStatistics` | | `Path` | `IStatistics` | +| `RandomAccess` | `IStatistics` | `IStatistics` exposes `Methods` and `Properties` collections. Each entry carries the call's name, parameters and a stack-frame counter so you can correlate concurrent activity. `IPathStatistics` adds an indexer keyed by the path that the factory produced - `stats.FileInfo["a.txt"]` returns just the calls made on that specific `IFileInfo`. From 58677a577b651343ca45074ee91c3038b7abb388 Mon Sep 17 00:00:00 2001 From: Mpdreamz Date: Sun, 20 Sep 2026 13:52:37 +0200 Subject: [PATCH 10/17] test: cover the `SafeFileHandle` surface and drop the stale exclusions The tests for the `SafeFileHandle` overloads were compiled out behind `EXECUTE_SAFEFILEHANDLE_TESTS`, which is defined nowhere, because obtaining a handle required platform invocation. The implementations carried `[ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")]` for the same reason. `IFile.OpenHandle` removes that constraint, so: - the 22 exclusions on members that the new tests exercise are removed; the two `ISafeFileHandleStrategy` implementations keep theirs, as nothing covers a handle that originates outside the mock, - the dead tests and their platform invocation helpers are removed, superseded by tests that obtain a handle from the abstraction and run against both the real and the mocked file system, - the documentation shows the new way in, and keeps the strategy for handles that come from elsewhere. Co-Authored-By: Claude Opus 5 --- .../FileSystem/FileStreamFactoryMock.cs | 9 - .../FileSystem/FileStreamFactory.cs | 9 - .../FileSystem/FileWrapper.cs | 48 ----- ...leStreamFactoryMockTests.SafeFileHandle.cs | 103 ----------- .../TestHelpers/UnmanagedFileLoader.cs | 70 ------- .../FileStreamFactory/SafeFileHandleTests.cs | 172 ------------------ .../TestHelpers/UnmanagedFileLoader.cs | 69 ------- 7 files changed, 480 deletions(-) delete mode 100644 Tests/Testably.Abstractions.Testing.Tests/FileSystem/FileStreamFactoryMockTests.SafeFileHandle.cs delete mode 100644 Tests/Testably.Abstractions.Testing.Tests/TestHelpers/UnmanagedFileLoader.cs delete mode 100644 Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/SafeFileHandleTests.cs delete mode 100644 Tests/Testably.Abstractions.Tests/TestHelpers/UnmanagedFileLoader.cs diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileStreamFactoryMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileStreamFactoryMock.cs index ce1ed9c2e..668242f94 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileStreamFactoryMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileStreamFactoryMock.cs @@ -144,9 +144,6 @@ private FileStreamMock New(string path, #endif /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public FileSystemStream New(SafeFileHandle handle, FileAccess access) { using IDisposable registration = _fileSystem.StatisticsRegistration @@ -162,9 +159,6 @@ public FileSystemStream New(SafeFileHandle handle, FileAccess access) } /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public FileSystemStream New(SafeFileHandle handle, FileAccess access, int bufferSize) { using IDisposable registration = _fileSystem.StatisticsRegistration @@ -181,9 +175,6 @@ public FileSystemStream New(SafeFileHandle handle, FileAccess access, int buffer } /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public FileSystemStream New(SafeFileHandle handle, FileAccess access, int bufferSize, bool isAsync) { diff --git a/Source/Testably.Abstractions/FileSystem/FileStreamFactory.cs b/Source/Testably.Abstractions/FileSystem/FileStreamFactory.cs index 55ff1b24d..8575ac2b5 100644 --- a/Source/Testably.Abstractions/FileSystem/FileStreamFactory.cs +++ b/Source/Testably.Abstractions/FileSystem/FileStreamFactory.cs @@ -60,23 +60,14 @@ public FileSystemStream New(string path, => Wrap(new FileStream(path, mode, access, share, bufferSize, options)); /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public FileSystemStream New(SafeFileHandle handle, FileAccess access) => Wrap(new FileStream(handle, access)); /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public FileSystemStream New(SafeFileHandle handle, FileAccess access, int bufferSize) => Wrap(new FileStream(handle, access, bufferSize)); /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public FileSystemStream New(SafeFileHandle handle, FileAccess access, int bufferSize, bool isAsync) => Wrap(new FileStream(handle, access, bufferSize, isAsync)); diff --git a/Source/Testably.Abstractions/FileSystem/FileWrapper.cs b/Source/Testably.Abstractions/FileSystem/FileWrapper.cs index 254c02df0..eb91af45d 100644 --- a/Source/Testably.Abstractions/FileSystem/FileWrapper.cs +++ b/Source/Testably.Abstractions/FileSystem/FileWrapper.cs @@ -186,9 +186,6 @@ public FileAttributes GetAttributes(string path) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public FileAttributes GetAttributes(SafeFileHandle fileHandle) => File.GetAttributes(fileHandle); #endif @@ -199,9 +196,6 @@ public DateTime GetCreationTime(string path) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public DateTime GetCreationTime(SafeFileHandle fileHandle) => File.GetCreationTime(fileHandle); #endif @@ -212,9 +206,6 @@ public DateTime GetCreationTimeUtc(string path) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public DateTime GetCreationTimeUtc(SafeFileHandle fileHandle) => File.GetCreationTimeUtc(fileHandle); #endif @@ -225,9 +216,6 @@ public DateTime GetLastAccessTime(string path) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public DateTime GetLastAccessTime(SafeFileHandle fileHandle) => File.GetLastAccessTime(fileHandle); #endif @@ -238,9 +226,6 @@ public DateTime GetLastAccessTimeUtc(string path) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public DateTime GetLastAccessTimeUtc(SafeFileHandle fileHandle) => File.GetLastAccessTimeUtc(fileHandle); #endif @@ -251,9 +236,6 @@ public DateTime GetLastWriteTime(string path) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public DateTime GetLastWriteTime(SafeFileHandle fileHandle) => File.GetLastWriteTime(fileHandle); #endif @@ -264,9 +246,6 @@ public DateTime GetLastWriteTimeUtc(string path) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public DateTime GetLastWriteTimeUtc(SafeFileHandle fileHandle) => File.GetLastWriteTimeUtc(fileHandle); #endif @@ -281,9 +260,6 @@ public UnixFileMode GetUnixFileMode(string path) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// [UnsupportedOSPlatform("windows")] -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public UnixFileMode GetUnixFileMode(SafeFileHandle fileHandle) => File.GetUnixFileMode(fileHandle); #endif @@ -451,9 +427,6 @@ public void SetAttributes(string path, FileAttributes fileAttributes) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public void SetAttributes(SafeFileHandle fileHandle, FileAttributes fileAttributes) => File.SetAttributes(fileHandle, fileAttributes); #endif @@ -464,9 +437,6 @@ public void SetCreationTime(string path, DateTime creationTime) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public void SetCreationTime(SafeFileHandle fileHandle, DateTime creationTime) => File.SetCreationTime(fileHandle, creationTime); #endif @@ -477,9 +447,6 @@ public void SetCreationTimeUtc(string path, DateTime creationTimeUtc) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public void SetCreationTimeUtc(SafeFileHandle fileHandle, DateTime creationTimeUtc) => File.SetCreationTimeUtc(fileHandle, creationTimeUtc); #endif @@ -490,9 +457,6 @@ public void SetLastAccessTime(string path, DateTime lastAccessTime) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public void SetLastAccessTime(SafeFileHandle fileHandle, DateTime lastAccessTime) => File.SetLastAccessTime(fileHandle, lastAccessTime); #endif @@ -503,9 +467,6 @@ public void SetLastAccessTimeUtc(string path, DateTime lastAccessTimeUtc) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public void SetLastAccessTimeUtc(SafeFileHandle fileHandle, DateTime lastAccessTimeUtc) => File.SetLastAccessTimeUtc(fileHandle, lastAccessTimeUtc); @@ -517,9 +478,6 @@ public void SetLastWriteTime(string path, DateTime lastWriteTime) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public void SetLastWriteTime(SafeFileHandle fileHandle, DateTime lastWriteTime) => File.SetLastWriteTime(fileHandle, lastWriteTime); #endif @@ -530,9 +488,6 @@ public void SetLastWriteTimeUtc(string path, DateTime lastWriteTimeUtc) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public void SetLastWriteTimeUtc(SafeFileHandle fileHandle, DateTime lastWriteTimeUtc) => File.SetLastWriteTimeUtc(fileHandle, lastWriteTimeUtc); #endif @@ -547,9 +502,6 @@ public void SetUnixFileMode(string path, UnixFileMode mode) #if FEATURE_FILESYSTEM_SAFEFILEHANDLE /// [UnsupportedOSPlatform("windows")] -#if NET6_0_OR_GREATER - [ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")] -#endif public void SetUnixFileMode(SafeFileHandle fileHandle, UnixFileMode mode) => File.SetUnixFileMode(fileHandle, mode); #endif diff --git a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/FileStreamFactoryMockTests.SafeFileHandle.cs b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/FileStreamFactoryMockTests.SafeFileHandle.cs deleted file mode 100644 index db4e47f4f..000000000 --- a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/FileStreamFactoryMockTests.SafeFileHandle.cs +++ /dev/null @@ -1,103 +0,0 @@ -#if EXECUTE_SAFEFILEHANDLE_TESTS -using Microsoft.Win32.SafeHandles; -using System.IO; -using Testably.Abstractions.Testing.FileSystem; -using Testably.Abstractions.Testing.Tests.TestHelpers; - -namespace Testably.Abstractions.Testing.Tests.FileSystem; - -public sealed partial class FileStreamFactoryMockTests -{ - [Test] - [AutoArguments] - public void MissingFile_ShouldThrowFileNotFoundException( - string path, string contents) - { - Skip.If(Test.IsNetFramework); - - path = RealFileSystem.Path.GetFullPath(path); - RealFileSystem.File.WriteAllText(path, contents); - - using SafeFileHandle handle = UnmanagedFileLoader.CreateSafeFileHandle(path); - handle.IsInvalid.Should().BeFalse(); - MockFileSystem.WithSafeFileHandleStrategy( - new DefaultSafeFileHandleStrategy(_ => new SafeFileHandleMock(path))); - - Exception? exception = Record.Exception(() => - { - // ReSharper disable once AccessToDisposedClosure - MockFileSystem.FileStream.New(handle, FileAccess.Read); - }); - - exception.Should().BeOfType() - .Which.Message.Should().Contain($"'{MockFileSystem.Path.GetFullPath(path)}'"); - } - - [Test] - [AutoArguments] - public void UnregisteredFileHandle_ShouldThrowNotSupportedException( - string path, string contents) - { - path = RealFileSystem.Path.GetFullPath(path); - RealFileSystem.File.WriteAllText(path, contents); - - using SafeFileHandle handle = UnmanagedFileLoader.CreateSafeFileHandle(path); - handle.IsInvalid.Should().BeFalse(); - - Exception? exception = Record.Exception(() => - { - // ReSharper disable once AccessToDisposedClosure - MockFileSystem.FileStream.New(handle, FileAccess.Read); - }); - - exception.Should().BeOfType() - .Which.Message.Should().Contain(nameof(MockFileSystem) + "." + - nameof(MockFileSystem.WithSafeFileHandleStrategy)); - } - - [Test] - [AutoArguments] - public void UnregisteredFileHandle_WithBufferSize_ShouldThrowNotSupportedException( - string path, string contents, int bufferSize) - { - path = RealFileSystem.Path.GetFullPath(path); - RealFileSystem.File.WriteAllText(path, contents); - - using SafeFileHandle handle = UnmanagedFileLoader.CreateSafeFileHandle(path); - handle.IsInvalid.Should().BeFalse(); - - Exception? exception = Record.Exception(() => - { - // ReSharper disable once AccessToDisposedClosure - MockFileSystem.FileStream.New(handle, FileAccess.Read, bufferSize); - }); - - exception.Should().BeOfType() - .Which.Message.Should().Contain(nameof(MockFileSystem) + "." + - nameof(MockFileSystem.WithSafeFileHandleStrategy)); - } - - [Test] - [AutoArguments] - public void - UnregisteredFileHandle_WithBufferSizeAndIsAsync_ShouldThrowNotSupportedException( - string path, string contents, int bufferSize, bool isAsync) - { - path = RealFileSystem.Path.GetFullPath(path); - RealFileSystem.File.WriteAllText(path, contents); - - using SafeFileHandle handle = UnmanagedFileLoader.CreateSafeFileHandle(path); - handle.IsInvalid.Should().BeFalse(); - - Exception? exception = Record.Exception(() => - { - // ReSharper disable once AccessToDisposedClosure - MockFileSystem.FileStream.New(handle, FileAccess.Read, bufferSize, isAsync); - }); - - exception.Should().BeOfType() - .Which.Message.Should().Contain(nameof(MockFileSystem) + "." + - nameof(MockFileSystem.WithSafeFileHandleStrategy)); - } -} -#endif diff --git a/Tests/Testably.Abstractions.Testing.Tests/TestHelpers/UnmanagedFileLoader.cs b/Tests/Testably.Abstractions.Testing.Tests/TestHelpers/UnmanagedFileLoader.cs deleted file mode 100644 index d4f7d18a9..000000000 --- a/Tests/Testably.Abstractions.Testing.Tests/TestHelpers/UnmanagedFileLoader.cs +++ /dev/null @@ -1,70 +0,0 @@ -#if EXECUTE_SAFEFILEHANDLE_TESTS -using Microsoft.Win32.SafeHandles; -using System.IO; -using System.Runtime.InteropServices; - -namespace Testably.Abstractions.Testing.Tests.TestHelpers; - -/// -/// -/// -public static class UnmanagedFileLoader -{ - private const uint CreateAlways = 2; - private const uint CreateNew = 1; - private const uint GenericRead = 0x80000000; - private const uint GenericWrite = 0x40000000; - private const uint OpenExisting = 3; - - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - private static extern SafeFileHandle CreateFile(string lpFileName, - uint dwDesiredAccess, - uint dwShareMode, - IntPtr lpSecurityAttributes, - uint dwCreationDisposition, - uint dwFlagsAndAttributes, - IntPtr hTemplateFile); - - public static SafeFileHandle CreateSafeFileHandle(string? path, - FileMode mode = FileMode.Open, - FileAccess access = - FileAccess.Write) - { - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentNullException(nameof(path)); - } - - uint desiredAccess = 0; - if (access.HasFlag(FileAccess.Read)) - { - desiredAccess |= GenericRead; - } - - if (access.HasFlag(FileAccess.Write)) - { - desiredAccess |= GenericWrite; - } - - uint creationDisposition; - switch (mode) - { - case FileMode.Open: - creationDisposition = OpenExisting; - break; - case FileMode.Create: - creationDisposition = CreateAlways; - break; - case FileMode.CreateNew: - creationDisposition = CreateNew; - break; - default: - throw new NotSupportedException($"File mode '{mode}' is not supported!"); - } - - return CreateFile(path, desiredAccess, 0, IntPtr.Zero, creationDisposition, 0, - IntPtr.Zero); - } -} -#endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/SafeFileHandleTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/SafeFileHandleTests.cs deleted file mode 100644 index f74995f00..000000000 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/SafeFileHandleTests.cs +++ /dev/null @@ -1,172 +0,0 @@ -#if EXECUTE_SAFEFILEHANDLE_TESTS -using Microsoft.Win32.SafeHandles; -using System.IO; -using System.Text; -using Testably.Abstractions.Testing.FileSystem; - -namespace Testably.Abstractions.Tests.FileSystem.FileStreamFactory; - -[FileSystemTests] -public class SafeFileHandleTests(FileSystemTestData testData) : FileSystemTestBase(testData) -{ - [Test] - [AutoArguments] - public async Task New_SafeFileHandle_InvalidHandle_ShouldThrowArgumentException( - string filename) - { - string path = FileSystem.Path.GetFullPath(filename); - SafeFileHandle handle = UnmanagedFileLoader.CreateSafeFileHandle(path); - - void Act() - { - FileSystem.FileStream.New(handle, FileAccess.ReadWrite); - } - - await That(Act).Throws() - .WithHResult(-2147024809).And - .WithParamName("handle"); - } - - [Test] - [AutoArguments] - public async Task New_SafeFileHandle_InvalidHandle_WithBufferSize_ShouldThrowArgumentException( - string filename) - { - string path = FileSystem.Path.GetFullPath(filename); - SafeFileHandle handle = UnmanagedFileLoader.CreateSafeFileHandle(path); - - void Act() - { - FileSystem.FileStream.New(handle, FileAccess.ReadWrite, 1024); - } - - await That(Act).Throws() - .WithHResult(-2147024809).And - .WithParamName("handle"); - } - - [Test] - [AutoArguments] - public async Task - New_SafeFileHandle_InvalidHandle_WithBufferSizeAndAsync_ShouldThrowArgumentException( - string filename) - { - string path = FileSystem.Path.GetFullPath(filename); - SafeFileHandle handle = UnmanagedFileLoader.CreateSafeFileHandle(path); - - void Act() - { - FileSystem.FileStream.New(handle, FileAccess.ReadWrite, 1024, true); - } - - await That(Act).Throws() - .WithHResult(-2147024809).And - .WithParamName("handle"); - } - - [Test] - [AutoArguments] - public async Task New_SafeFileHandle_Valid_ShouldCreateWritableStream( - string filename, string contents) - { - IDisposable? cleanup = null; - if (FileSystem is not RealFileSystem realFileSystem) - { - realFileSystem = new RealFileSystem(); - cleanup = realFileSystem.SetCurrentDirectoryToEmptyTemporaryDirectory(); - FileSystem.InitializeIn(realFileSystem.Directory.GetCurrentDirectory()); - } - - try - { - string path = realFileSystem.Path.GetFullPath(filename); - realFileSystem.File.WriteAllText(path, contents); - FileSystem.File.WriteAllText(path, contents); - using SafeFileHandle handle = UnmanagedFileLoader.CreateSafeFileHandle(path); - (FileSystem as MockFileSystem)?.WithSafeFileHandleStrategy( - new DefaultSafeFileHandleStrategy(_ => new SafeFileHandleMock(path))); - - FileSystemStream stream = FileSystem.FileStream.New(handle, FileAccess.ReadWrite); - stream.Write(Encoding.UTF8.GetBytes("foo"), 0, 3); - stream.Dispose(); - - FileSystem.File.ReadAllText(path).Should().StartWith("foo"); - } - finally - { - cleanup?.Dispose(); - } - } - - [Test] - [AutoArguments] - public async Task New_SafeFileHandle_Valid_WithBufferSize_ShouldCreateWritableStream( - string filename, string contents) - { - IDisposable? cleanup = null; - if (FileSystem is not RealFileSystem realFileSystem) - { - realFileSystem = new RealFileSystem(); - cleanup = realFileSystem.SetCurrentDirectoryToEmptyTemporaryDirectory(); - FileSystem.InitializeIn(realFileSystem.Directory.GetCurrentDirectory()); - } - - try - { - string path = realFileSystem.Path.GetFullPath(filename); - realFileSystem.File.WriteAllText(path, contents); - FileSystem.File.WriteAllText(path, contents); - using SafeFileHandle handle = UnmanagedFileLoader.CreateSafeFileHandle(path); - (FileSystem as MockFileSystem)?.WithSafeFileHandleStrategy( - new DefaultSafeFileHandleStrategy(_ => new SafeFileHandleMock(path))); - - FileSystemStream stream = - FileSystem.FileStream.New(handle, FileAccess.ReadWrite, 1024); - stream.Write(Encoding.UTF8.GetBytes("foo"), 0, 3); - stream.Dispose(); - - FileSystem.File.ReadAllText(path).Should().StartWith("foo"); - } - finally - { - cleanup?.Dispose(); - } - } - - [Test] - [AutoArguments] - public async Task - New_SafeFileHandle_Valid_WithBufferSizeAndAsync_ShouldCreateWritableStream( - string filename, string contents) - { - IDisposable? cleanup = null; - if (FileSystem is not RealFileSystem realFileSystem) - { - realFileSystem = new RealFileSystem(); - cleanup = realFileSystem.SetCurrentDirectoryToEmptyTemporaryDirectory(); - FileSystem.InitializeIn(realFileSystem.Directory.GetCurrentDirectory()); - } - - try - { - string path = realFileSystem.Path.GetFullPath(filename); - realFileSystem.File.WriteAllText(path, contents); - FileSystem.File.WriteAllText(path, contents); - using SafeFileHandle handle = UnmanagedFileLoader.CreateSafeFileHandle(path); - (FileSystem as MockFileSystem)?.WithSafeFileHandleStrategy( - new DefaultSafeFileHandleStrategy(_ => new SafeFileHandleMock(path))); - - FileSystemStream stream = - FileSystem.FileStream.New(handle, FileAccess.ReadWrite, 1024, false); - stream.Write(Encoding.UTF8.GetBytes("foo"), 0, 3); - stream.Dispose(); - - FileSystem.File.ReadAllText(path).Should().StartWith("foo"); - } - finally - { - cleanup?.Dispose(); - } - } -} -#endif diff --git a/Tests/Testably.Abstractions.Tests/TestHelpers/UnmanagedFileLoader.cs b/Tests/Testably.Abstractions.Tests/TestHelpers/UnmanagedFileLoader.cs deleted file mode 100644 index c803edbe2..000000000 --- a/Tests/Testably.Abstractions.Tests/TestHelpers/UnmanagedFileLoader.cs +++ /dev/null @@ -1,69 +0,0 @@ -#if EXECUTE_SAFEFILEHANDLE_TESTS -using Microsoft.Win32.SafeHandles; -using System.IO; -using System.Runtime.InteropServices; - -namespace Testably.Abstractions.Tests.TestHelpers; - -/// -/// -/// -public static class UnmanagedFileLoader -{ - private const uint CreateAlways = 2; - private const uint CreateNew = 1; - private const uint GenericRead = 0x80000000; - private const uint GenericWrite = 0x40000000; - private const uint OpenExisting = 3; - - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - private static extern SafeFileHandle CreateFile(string lpFileName, - uint dwDesiredAccess, - uint dwShareMode, - IntPtr lpSecurityAttributes, - uint dwCreationDisposition, - uint dwFlagsAndAttributes, - IntPtr hTemplateFile); - - public static SafeFileHandle CreateSafeFileHandle(string? path, - FileMode mode = FileMode.Open, - FileAccess access = FileAccess.Write) - { - if (string.IsNullOrEmpty(path)) - { - throw new ArgumentNullException(nameof(path)); - } - - uint desiredAccess = 0; - if (access.HasFlag(FileAccess.Read)) - { - desiredAccess |= GenericRead; - } - - if (access.HasFlag(FileAccess.Write)) - { - desiredAccess |= GenericWrite; - } - - uint creationDisposition; - switch (mode) - { - case FileMode.Open: - creationDisposition = OpenExisting; - break; - case FileMode.Create: - creationDisposition = CreateAlways; - break; - case FileMode.CreateNew: - creationDisposition = CreateNew; - break; - default: - throw new NotSupportedException($"File mode '{mode}' is not supported!"); - } - - return CreateFile(path!, desiredAccess, 0, IntPtr.Zero, creationDisposition, 0, - IntPtr.Zero); - } -} -#endif From a94fae021a32f71d5bd247f0500637eae7185bbc Mon Sep 17 00:00:00 2001 From: Mpdreamz Date: Sun, 20 Sep 2026 15:54:25 +0200 Subject: [PATCH 11/17] fix: exercise the asynchronous handle overload and mend the docs example The test for `New(SafeFileHandle, FileAccess, int, bool)` passed `isAsync: false`, so it never covered the asynchronous path its name claimed. It now opens the handle with `FileOptions.Asynchronous`, asserts `IsAsync` and reads asynchronously, with a second test for the synchronous case. The documentation still reached for `UnmanagedFileLoader` further down the page, which this change removes; the example now opens the handle through the real file system. Co-Authored-By: Claude Opus 5 --- .../OpenHandleStreamTests.cs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/OpenHandleStreamTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/OpenHandleStreamTests.cs index 2d7754460..f2358e1a5 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/OpenHandleStreamTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/OpenHandleStreamTests.cs @@ -43,13 +43,29 @@ public async Task New_WithHandleAndBufferSizeAndAsync_ShouldReadTheFileContent( { FileSystem.File.WriteAllText(path, contents); + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Read, FileShare.ReadWrite, FileOptions.Asynchronous); + using FileSystemStream stream = + FileSystem.FileStream.New(handle, FileAccess.Read, 1024, true); + using StreamReader reader = new(stream); + + await That(stream.IsAsync).IsTrue(); + await That(await reader.ReadToEndAsync()).IsEqualTo(contents); + } + + [Test] + [AutoArguments] + public async Task New_WithHandleAndIsAsyncFalse_ShouldCreateASynchronousStream( + string path, string contents) + { + FileSystem.File.WriteAllText(path, contents); + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using FileSystemStream stream = FileSystem.FileStream.New(handle, FileAccess.Read, 1024, false); - using StreamReader reader = new(stream); - await That(reader.ReadToEnd()).IsEqualTo(contents); + await That(stream.IsAsync).IsFalse(); } [Test] From d140451d0f92a8ac1c4402744d9e6d42c34a4eff Mon Sep 17 00:00:00 2001 From: mpdreamz Date: Fri, 25 Sep 2026 20:23:38 +0200 Subject: [PATCH 12/17] chore: leave the test project files as they are on main The only remaining difference was whitespace before `/>`, left over from switching these references to projects and back. Co-Authored-By: Claude Opus 5.5 --- .../Testably.Abstractions.TestHelpers.csproj | 4 ++-- .../Testably.Abstractions.Testing.Tests.csproj | 2 +- .../Testably.Abstractions.Tests.csproj | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/Helpers/Testably.Abstractions.TestHelpers/Testably.Abstractions.TestHelpers.csproj b/Tests/Helpers/Testably.Abstractions.TestHelpers/Testably.Abstractions.TestHelpers.csproj index e7ebccc51..31a77762b 100644 --- a/Tests/Helpers/Testably.Abstractions.TestHelpers/Testably.Abstractions.TestHelpers.csproj +++ b/Tests/Helpers/Testably.Abstractions.TestHelpers/Testably.Abstractions.TestHelpers.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/Tests/Testably.Abstractions.Testing.Tests/Testably.Abstractions.Testing.Tests.csproj b/Tests/Testably.Abstractions.Testing.Tests/Testably.Abstractions.Testing.Tests.csproj index d4c4b2970..70b2b5912 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/Testably.Abstractions.Testing.Tests.csproj +++ b/Tests/Testably.Abstractions.Testing.Tests/Testably.Abstractions.Testing.Tests.csproj @@ -13,7 +13,7 @@ - + diff --git a/Tests/Testably.Abstractions.Tests/Testably.Abstractions.Tests.csproj b/Tests/Testably.Abstractions.Tests/Testably.Abstractions.Tests.csproj index 946e20949..a31e9bc1d 100644 --- a/Tests/Testably.Abstractions.Tests/Testably.Abstractions.Tests.csproj +++ b/Tests/Testably.Abstractions.Tests/Testably.Abstractions.Tests.csproj @@ -1,7 +1,7 @@  - + From 44fad35b6ff11504c77404ee25f5ebbbca103aaf Mon Sep 17 00:00:00 2001 From: mpdreamz Date: Fri, 25 Sep 2026 20:23:38 +0200 Subject: [PATCH 13/17] refactor: share the open checks between `FileStreamMock` and `OpenHandle` `FileModeHelper.GetFileContainer` now holds what both did twice: a missing file for `Open`/`Truncate`, creating it otherwise, a directory at the path, `CreateNew` on an existing file and a read-only file opened for writing. `FileStreamMock` calls `FileModeHelper` directly and loses the forwarding method. Co-Authored-By: Claude Opus 5.5 --- .../FileSystem/FileStreamMock.cs | 45 ++--------------- .../Helpers/FileModeHelper.cs | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 42 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs index 02d5de944..1076ff7cc 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs @@ -227,7 +227,7 @@ FileOptions options path == null ? "" : fileSystem.Execute.Path.GetFullPath(path), (options & FileOptions.Asynchronous) != 0) { - ThrowIfInvalidModeAccess(mode, access); + FileModeHelper.ThrowIfInvalidModeAccess(mode, access); _stream = stream; _fileSystem = fileSystem; @@ -238,44 +238,8 @@ FileOptions options _initialPosition = base.Position; _location = _fileSystem.Storage.GetLocation(base.Name); - _location.ThrowExceptionIfNotFound(_fileSystem, true); - IStorageContainer file = _fileSystem.Storage.GetContainer(_location); - if (file is NullContainer) - { - if (_mode.Equals(FileMode.Open) || - _mode.Equals(FileMode.Truncate)) - { - throw ExceptionFactory.FileNotFound( - _fileSystem.Execute.Path.GetFullPath(base.Name)); - } - - file = _fileSystem.Storage.GetOrCreateContainer(_location, - InMemoryContainer.NewFile, - this); - } - else if (file.Type == FileSystemTypes.Directory) - { - if (_fileSystem.Execute.IsWindows) - { - throw ExceptionFactory.AccessToPathDenied( - _fileSystem.Execute.Path.GetFullPath(base.Name)); - } - - throw ExceptionFactory.FileAlreadyExists( - _fileSystem.Execute.Path.GetFullPath(base.Name), 17); - } - else if (_mode.Equals(FileMode.CreateNew)) - { - throw ExceptionFactory.FileAlreadyExists( - _fileSystem.Execute.Path.GetFullPath(Name), - _fileSystem.Execute.IsWindows ? -2147024816 : 17); - } - - if (file.Attributes.HasFlag(FileAttributes.ReadOnly) && - access.HasFlag(FileAccess.Write)) - { - throw ExceptionFactory.AccessToPathDenied(_location.FullPath); - } + IStorageContainer file = FileModeHelper.GetFileContainer(_fileSystem, + _location, mode, access, this); #if FEATURE_FILESYSTEM_UNIXFILEMODE if (unixFileMode.HasValue) { @@ -899,7 +863,4 @@ private void ThrowIfDisposed() throw new ObjectDisposedException("", "Cannot access a closed file."); } } - - private static void ThrowIfInvalidModeAccess(FileMode mode, FileAccess access) - => FileModeHelper.ThrowIfInvalidModeAccess(mode, access); } diff --git a/Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs b/Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs index 0b02203e9..08f26a5e4 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs @@ -1,9 +1,58 @@ using System.IO; +using Testably.Abstractions.Helpers; +using Testably.Abstractions.Testing.Storage; namespace Testably.Abstractions.Testing.Helpers; internal static class FileModeHelper { + /// + /// Returns the file container to open at in , creating it + /// when the mode allows, and throws what the real file system throws when it cannot be opened. + /// + internal static IStorageContainer GetFileContainer(MockFileSystem fileSystem, + IStorageLocation location, + FileMode mode, + FileAccess access, + IFileSystemExtensibility? extensibility = null) + { + location.ThrowExceptionIfNotFound(fileSystem, true); + IStorageContainer container = fileSystem.Storage.GetContainer(location); + if (container is NullContainer) + { + if (mode is FileMode.Open or FileMode.Truncate) + { + throw ExceptionFactory.FileNotFound(location.FullPath); + } + + container = fileSystem.Storage.GetOrCreateContainer(location, + InMemoryContainer.NewFile, + extensibility); + } + else if (container.Type == FileSystemTypes.Directory) + { + if (fileSystem.Execute.IsWindows) + { + throw ExceptionFactory.AccessToPathDenied(location.FullPath); + } + + throw ExceptionFactory.FileAlreadyExists(location.FullPath, 17); + } + else if (mode == FileMode.CreateNew) + { + throw ExceptionFactory.FileAlreadyExists(location.FullPath, + fileSystem.Execute.IsWindows ? -2147024816 : 17); + } + + if (container.Attributes.HasFlag(FileAttributes.ReadOnly) && + access.HasFlag(FileAccess.Write)) + { + throw ExceptionFactory.AccessToPathDenied(location.FullPath); + } + + return container; + } + internal static void ThrowIfInvalidModeAccess(FileMode mode, FileAccess access) { if (mode == FileMode.Append) From ff6e2d945b2971f862f1d072ba57c0fe5c2d3b88 Mon Sep 17 00:00:00 2001 From: mpdreamz Date: Fri, 25 Sep 2026 20:23:39 +0200 Subject: [PATCH 14/17] fix: address the review of the handle registry and `RandomAccessMock` Handle lifetime - Closed handles are no longer released from the `MockFileSystem.Storage` getter. `InMemoryStorage` calls `ReleaseClosedHandles()` explicitly where a closed handle becomes observable: `GetContainer`, `EnumerateLocations` and `TryGetFileAccess`. - The sweep no longer throws. A delete-on-close that fails (the parent is gone, a directory took the name) is ignored, as the OS ignores it. - On Windows a pending delete waits until nothing holds the file, streams included, instead of being dropped when a stream still has it open. - The Windows branch no longer follows a renamed file: moving a file a handle holds open is not possible in the mock on Windows yet (#1086), so that behaviour could not be observed. `IStorage.GetLocation( IStorageContainer)` goes with it. - The registry holds handles weakly, so one that is dropped without `Dispose()` releases its share lock once it is collected, as a real handle does when it is finalized. `OpenHandle` - Arguments are validated in the order of the runtime's `FileStreamHelpers.ValidateArguments`: path, then the enum ranges, options, `preallocationSize`, the mode/access combination, and finally preallocation on a non-creating mode or without write access. - `Map` and the handle lookup guard against `null`, and a closed foreign handle is rejected on every path. `RandomAccessMock` - Writes go through `IStorageContainer.WriteRange`, so an open `FileStreamMock` gets a range update and a vetoing interception sees the content unchanged. - An offset beyond what the content can hold throws `IOException` instead of overflowing. - `FileMode` is no longer threaded through; `GetContainerForResize` is behind `FEATURE_RANDOMACCESS_FLUSHTODISK`; the buffers are recorded in the statistics like the rest of the mock records them. Also: `Entry` is a record, `IsKnown` and the redundant `NullContainer` check are gone, `ExceptionFactory` additions are in alphabetical order, the redundant `AllHandleOverloads_ShouldAgreeWithThePathOverloads` test is removed and the skip reasons point at #1086 again. Co-Authored-By: Claude Opus 5.5 --- .../FileSystem/MockSafeFileHandleRegistry.cs | 342 +++++++++--------- .../FileSystem/RandomAccessMock.cs | 63 ++-- .../Helpers/ExceptionFactory.cs | 24 +- .../MockFileSystem.cs | 13 +- .../Storage/IStorage.cs | 7 - .../Storage/InMemoryStorage.cs | 23 +- .../FileSystem/MockSafeFileHandleTests.cs | 157 ++++++++ .../FileSystem/File/OpenHandleTests.cs | 120 ++++++ .../FileSystem/File/SafeFileHandleTests.cs | 24 -- .../RandomAccess/HandleIdentityTests.cs | 30 +- 10 files changed, 530 insertions(+), 273 deletions(-) create mode 100644 Tests/Testably.Abstractions.Testing.Tests/FileSystem/MockSafeFileHandleTests.cs diff --git a/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs index 2eec5e0ef..a38e0ca3f 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs @@ -26,6 +26,14 @@ internal sealed class MockSafeFileHandleRegistry /// private const long FirstHandleValue = 0x4000_0000L; + private const FileOptions ValidFileOptions = FileOptions.WriteThrough | + FileOptions.Asynchronous | + FileOptions.RandomAccess | + FileOptions.DeleteOnClose | + FileOptions.SequentialScan | + FileOptions.Encrypted | + (FileOptions)0x20000000; // NoBuffering + private readonly Dictionary _entries = new(); private readonly MockFileSystem _fileSystem; #if NET9_0_OR_GREATER @@ -54,57 +62,17 @@ internal SafeFileHandle Open(string path, FileOptions options, long preallocationSize) { - if (preallocationSize < 0) - { - throw ExceptionFactory.NonNegativeNumberRequired("preallocationSize"); - } - - FileModeHelper.ThrowIfInvalidModeAccess(mode, access); - - ReleaseClosedHandles(); - - IStorageLocation location = _fileSystem.Storage - .GetLocation(path.EnsureValidFormat(_fileSystem)); - location.ThrowExceptionIfNotFound(_fileSystem, true); - - IStorageContainer container = _fileSystem.Storage.GetContainer(location); - if (container is NullContainer) - { - if (mode == FileMode.Open || mode == FileMode.Truncate) - { - throw ExceptionFactory.FileNotFound(location.FullPath); - } - - container = _fileSystem.Storage - .GetOrCreateContainer(location, InMemoryContainer.NewFile); - } - else if (container.Type == FileSystemTypes.Directory) - { - if (_fileSystem.Execute.IsWindows) - { - throw ExceptionFactory.AccessToPathDenied(location.FullPath); - } - - throw ExceptionFactory.FileAlreadyExists(location.FullPath, 17); - } - else if (mode == FileMode.CreateNew) - { - throw ExceptionFactory.FileAlreadyExists(location.FullPath, - _fileSystem.Execute.IsWindows ? -2147024816 : 17); - } - - if (container.Attributes.HasFlag(FileAttributes.ReadOnly) && - access.HasFlag(FileAccess.Write)) - { - throw ExceptionFactory.AccessToPathDenied(location.FullPath); - } + IStorageLocation location = _fileSystem.Storage.GetLocation( + ValidateArguments(path, mode, access, share, options, preallocationSize)); + IStorageContainer container = + FileModeHelper.GetFileContainer(_fileSystem, location, mode, access); // `deleteAccess` marks a delete *operation* — `FileHandle.GrantAccess` requires every other handle to have - // been opened with exactly `FileShare.Delete` on Windows — so it is not what `FileOptions.DeleteOnClose` - // means. Opening for deletion-on-close is an ordinary open; the deletion happens when the handle closes. + // been opened with `FileShare.Delete` on Windows — so it is not what `FileOptions.DeleteOnClose` means. + // Opening for deletion-on-close is an ordinary open; the deletion happens when the handle closes. IStorageAccessHandle accessLock = container.RequestAccess(access, share); - if (mode == FileMode.Create || mode == FileMode.Truncate) + if (mode is FileMode.Create or FileMode.Truncate) { container.WriteBytes([]); } @@ -114,99 +82,49 @@ internal SafeFileHandle Open(string path, IntPtr value = new(_nextHandleValue++); SafeFileHandle handle = new(value, ownsHandle: false); _entries[value] = new Entry( - handle, + new WeakReference(handle), new SafeFileHandleMock(location.FullPath, mode, share), accessLock, location, container, access, - mode, options); _hasWork = true; return handle; } } - internal Entry? Resolve(SafeFileHandle handle) - { - ReleaseClosedHandles(); - - lock (_lock) - { - IntPtr value = handle.DangerousGetHandle(); - if (_entries.TryGetValue(value, out Entry? entry)) - { - if (handle.IsClosed) - { - throw ExceptionFactory.HandleIsClosed(); - } - - return entry; - } - - // Handle values are issued sequentially and never reused, so a value within the issued range that is no - // longer registered belonged to a handle this registry created and the caller has since closed. - if (WasIssued(value)) - { - throw ExceptionFactory.HandleIsClosed(); - } - - return null; - } - } - - internal bool IsKnown(SafeFileHandle handle) - { - lock (_lock) - { - IntPtr value = handle.DangerousGetHandle(); - return _entries.ContainsKey(value) || WasIssued(value); - } - } - - internal SafeFileHandleMock Map(SafeFileHandle handle) - => Resolve(handle)?.Mock - ?? _fileSystem.SafeFileHandleStrategy.MapSafeFileHandle(handle); - /// /// A handle refers to the file that was opened, not to its name, so one this registry created resolves to the /// container it was opened on. Only a handle from elsewhere, which carries nothing but a path, is looked up by /// name. /// - internal (IStorageContainer Container, FileAccess Access, FileMode Mode) GetContainer( - SafeFileHandle handle) + internal (IStorageContainer Container, FileAccess Access) GetContainer(SafeFileHandle handle) { - if (handle is null) - { - throw new ArgumentNullException(nameof(handle)); - } - if (Resolve(handle) is { } entry) { - return (entry.Container, entry.Access, entry.Mode); - } - - if (handle.IsClosed) - { - throw ExceptionFactory.HandleIsClosed(); + return (entry.Container, entry.Access); } - SafeFileHandleMock mock = _fileSystem.SafeFileHandleStrategy.MapSafeFileHandle(handle); + SafeFileHandleMock mock = MapForeign(handle); IStorageContainer container = _fileSystem.Storage .GetContainer(_fileSystem.Storage.GetLocation(mock.Path) .ThrowExceptionIfNotFound(_fileSystem)); - if (container is NullContainer) - { - throw ExceptionFactory.FileNotFound(mock.Path); - } - - return (container, FileAccess.ReadWrite, mock.Mode); + return (container, FileAccess.ReadWrite); } + internal SafeFileHandleMock Map(SafeFileHandle handle) + => Resolve(handle)?.Mock ?? MapForeign(handle); + /// - /// is sealed, so the mock cannot be notified when one is closed and instead - /// notices here, on the next registry access. + /// is sealed, so the mock cannot be notified when one is closed. Instead, the + /// storage calls this before it answers whether a file exists or may be opened, which is where a closed handle + /// becomes observable. /// + /// + /// A closed handle is released here, together with its share lock and any deletion it requested, and nothing + /// here throws: a real file system also ignores a delete-on-close that fails. + /// internal void ReleaseClosedHandles() { if (!_hasWork) @@ -214,7 +132,8 @@ internal void ReleaseClosedHandles() return; } - List? released = null; + List released = []; + List closed = []; lock (_lock) { if (_sweeping) @@ -222,24 +141,24 @@ internal void ReleaseClosedHandles() return; } - List? closed = null; foreach (KeyValuePair item in _entries) { - if (item.Value.Handle.IsClosed) + if (!item.Value.Handle.TryGetTarget(out SafeFileHandle? handle) || + handle.IsClosed) { - (closed ??= []).Add(item.Key); + closed.Add(item.Key); + released.Add(item.Value); } } - if (closed is null) + if (released.Count == 0 && _pendingDeletes.Count == 0) { return; } - foreach (IntPtr key in closed) + foreach (IntPtr value in closed) { - (released ??= []).Add(_entries[key]); - _entries.Remove(key); + _entries.Remove(value); } _sweeping = true; @@ -247,7 +166,7 @@ internal void ReleaseClosedHandles() try { - foreach (Entry entry in released!) + foreach (Entry entry in released) { entry.AccessLock.Dispose(); if (!entry.Options.HasFlag(FileOptions.DeleteOnClose)) @@ -261,29 +180,14 @@ internal void ReleaseClosedHandles() } else { - // Unix unlinks the name that was opened, as soon as this handle closes and whatever else still - // holds the file open — so a file renamed since then survives, and a replacement under the old - // name does not. - _fileSystem.Storage.DeleteContainer(entry.Location, FileSystemTypes.File); + // Unix unlinks the name that was opened as soon as this handle closes, whatever else still holds + // the file open. + TryDelete(entry.Location); } } - // Windows removes the file once the last handle to it closes, and follows it across a rename. - for (int i = _pendingDeletes.Count - 1; i >= 0; i--) - { - Entry pending = _pendingDeletes[i]; - if (IsStillOpen(pending.Container)) - { - continue; - } - - _pendingDeletes.RemoveAt(i); - IStorageLocation? current = _fileSystem.Storage.GetLocation(pending.Container); - if (current is not null) - { - _fileSystem.Storage.DeleteContainer(current, FileSystemTypes.File); - } - } + // Windows removes the file once the last handle to it closes. + _pendingDeletes.RemoveAll(IsDeletedOrGone); } finally { @@ -295,59 +199,149 @@ internal void ReleaseClosedHandles() } } - private bool IsStillOpen(IStorageContainer container) + private bool IsDeletedOrGone(Entry pending) + { + if (!ReferenceEquals(_fileSystem.Storage.GetContainer(pending.Location), + pending.Container)) + { + return true; + } + + // Exclusive access is only granted when nothing else, handle or stream, holds the file. + if (!_fileSystem.Storage.TryGetFileAccess(pending.Location, FileAccess.ReadWrite, + FileShare.None, deleteAccess: false, ignoreFileShare: false, + out FileHandle? probe)) + { + return false; + } + + probe.Dispose(); + TryDelete(pending.Location); + return true; + } + + private SafeFileHandleMock MapForeign(SafeFileHandle handle) { + if (handle.IsClosed) + { + throw ExceptionFactory.HandleIsClosed(); + } + + return _fileSystem.SafeFileHandleStrategy.MapSafeFileHandle(handle); + } + + private Entry? Resolve(SafeFileHandle handle) + { + if (handle is null) + { + throw new ArgumentNullException(nameof(handle)); + } + lock (_lock) { - foreach (KeyValuePair item in _entries) + IntPtr value = handle.DangerousGetHandle(); + if (_entries.TryGetValue(value, out Entry? entry)) { - if (ReferenceEquals(item.Value.Container, container)) + if (handle.IsClosed) { - return true; + throw ExceptionFactory.HandleIsClosed(); } + + return entry; } - return false; + // Handle values are issued sequentially and never reused, so a value within the issued range that is no + // longer registered belonged to a handle this registry created and the caller has since closed. + long candidate = value.ToInt64(); + if (candidate >= FirstHandleValue && candidate < _nextHandleValue) + { + throw ExceptionFactory.HandleIsClosed(); + } + + return null; } } - private bool WasIssued(IntPtr value) + private void TryDelete(IStorageLocation location) { - long candidate = value.ToInt64(); - return candidate >= FirstHandleValue && candidate < _nextHandleValue; + try + { + _fileSystem.Storage.DeleteContainer(location, FileSystemTypes.File); + } + catch (IOException) + { + // The name is gone, or its directory is. + } + catch (UnauthorizedAccessException) + { + // A directory now has the name. + } } - internal sealed class Entry + /// + /// Validates in the order of the runtime's FileStreamHelpers.ValidateArguments, so that a call with more + /// than one invalid argument reports the same one. + /// + private string ValidateArguments(string path, + FileMode mode, + FileAccess access, + FileShare share, + FileOptions options, + long preallocationSize) { - internal Entry(SafeFileHandle handle, - SafeFileHandleMock mock, - IStorageAccessHandle accessLock, - IStorageLocation location, - IStorageContainer container, - FileAccess access, - FileMode mode, - FileOptions options) + path = path.EnsureValidFormat(_fileSystem, nameof(path), includeIsEmptyCheck: true); + + if (mode is < FileMode.CreateNew or > FileMode.Append) { - Handle = handle; - Mock = mock; - AccessLock = accessLock; - Location = location; - Container = container; - Access = access; - Mode = mode; - Options = options; + throw ExceptionFactory.EnumValueOutOfRange(nameof(mode)); } - internal FileAccess Access { get; } - internal IStorageAccessHandle AccessLock { get; } - internal IStorageContainer Container { get; } - internal SafeFileHandle Handle { get; } + if (access is < FileAccess.Read or > FileAccess.ReadWrite) + { + throw ExceptionFactory.EnumValueOutOfRange(nameof(access)); + } - internal IStorageLocation Location { get; } + FileShare shareWithoutInheritable = share & ~FileShare.Inheritable; + if (shareWithoutInheritable is < FileShare.None or > (FileShare.ReadWrite | FileShare.Delete)) + { + throw ExceptionFactory.EnumValueOutOfRange(nameof(share)); + } + + if ((options & ~ValidFileOptions) != 0) + { + throw ExceptionFactory.EnumValueOutOfRange(nameof(options)); + } + + if (preallocationSize < 0) + { + throw ExceptionFactory.NonNegativeNumberRequired(nameof(preallocationSize)); + } + + FileModeHelper.ThrowIfInvalidModeAccess(mode, access); - internal FileMode Mode { get; } - internal SafeFileHandleMock Mock { get; } - internal FileOptions Options { get; } + if (preallocationSize > 0) + { + if (!access.HasFlag(FileAccess.Write)) + { + throw ExceptionFactory.PreallocationRequiresWriteAccess(access); + } + + if (mode is not (FileMode.Create or FileMode.CreateNew)) + { + throw ExceptionFactory.PreallocationRequiresNewFile(mode); + } + } + + return path; } + + internal sealed record Entry( + WeakReference Handle, + SafeFileHandleMock Mock, + IStorageAccessHandle AccessLock, + IStorageLocation Location, + IStorageContainer Container, + FileAccess Access, + FileOptions Options); } #endif diff --git a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs index cdb1fb77e..96581e5ec 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs @@ -55,7 +55,7 @@ public long GetLength(SafeFileHandle handle) public int Read(SafeFileHandle handle, Span buffer, long fileOffset) { using IDisposable registration = _fileSystem.StatisticsRegistration - .RandomAccess.RegisterMethod(nameof(Read), handle, fileOffset); + .RandomAccess.RegisterMethod(nameof(Read), handle, buffer, fileOffset); return ReadInto(handle, buffer, fileOffset); } @@ -64,7 +64,7 @@ public int Read(SafeFileHandle handle, Span buffer, long fileOffset) public long Read(SafeFileHandle handle, IReadOnlyList> buffers, long fileOffset) { using IDisposable registration = _fileSystem.StatisticsRegistration - .RandomAccess.RegisterMethod(nameof(Read), handle, fileOffset); + .RandomAccess.RegisterMethod(nameof(Read), handle, buffers, fileOffset); if (buffers is null) { @@ -84,7 +84,7 @@ public ValueTask ReadAsync(SafeFileHandle handle, Memory buffer, long } using IDisposable registration = _fileSystem.StatisticsRegistration - .RandomAccess.RegisterMethod(nameof(ReadAsync), handle, fileOffset, + .RandomAccess.RegisterMethod(nameof(ReadAsync), handle, buffer, fileOffset, cancellationToken); return new ValueTask(Read(handle, buffer.Span, fileOffset)); @@ -100,7 +100,7 @@ public ValueTask ReadAsync(SafeFileHandle handle, IReadOnlyList(Read(handle, buffers, fileOffset)); @@ -115,7 +115,7 @@ public void SetLength(SafeFileHandle handle, long length) if (length < 0) { - throw ExceptionFactory.NonNegativeNumberRequired("length"); + throw ExceptionFactory.NonNegativeNumberRequired(nameof(length)); } IStorageContainer container = GetContainerForResize(handle); @@ -133,7 +133,7 @@ public void SetLength(SafeFileHandle handle, long length) public void Write(SafeFileHandle handle, ReadOnlySpan buffer, long fileOffset) { using IDisposable registration = _fileSystem.StatisticsRegistration - .RandomAccess.RegisterMethod(nameof(Write), handle, fileOffset); + .RandomAccess.RegisterMethod(nameof(Write), handle, buffer, fileOffset); WriteBytes(handle, buffer.ToArray(), fileOffset); } @@ -143,7 +143,7 @@ public void Write(SafeFileHandle handle, IReadOnlyList> buf long fileOffset) { using IDisposable registration = _fileSystem.StatisticsRegistration - .RandomAccess.RegisterMethod(nameof(Write), handle, fileOffset); + .RandomAccess.RegisterMethod(nameof(Write), handle, buffers, fileOffset); if (buffers is null) { @@ -163,7 +163,7 @@ public ValueTask WriteAsync(SafeFileHandle handle, ReadOnlyMemory buffer, } using IDisposable registration = _fileSystem.StatisticsRegistration - .RandomAccess.RegisterMethod(nameof(WriteAsync), handle, fileOffset, + .RandomAccess.RegisterMethod(nameof(WriteAsync), handle, buffer, fileOffset, cancellationToken); Write(handle, buffer.Span, fileOffset); @@ -181,7 +181,7 @@ public ValueTask WriteAsync(SafeFileHandle handle, } using IDisposable registration = _fileSystem.StatisticsRegistration - .RandomAccess.RegisterMethod(nameof(WriteAsync), handle, fileOffset, + .RandomAccess.RegisterMethod(nameof(WriteAsync), handle, buffers, fileOffset, cancellationToken); Write(handle, buffers, fileOffset); @@ -209,9 +209,10 @@ private static byte[] Gather(IReadOnlyList> buffers) return bytes; } +#if FEATURE_RANDOMACCESS_FLUSHTODISK private IStorageContainer GetContainerForResize(SafeFileHandle handle) { - (IStorageContainer container, FileAccess access, _) = ResolveEntry(handle); + (IStorageContainer container, FileAccess access) = ResolveEntry(handle); if (access.HasFlag(FileAccess.Write)) { return container; @@ -223,25 +224,20 @@ private IStorageContainer GetContainerForResize(SafeFileHandle handle) : ExceptionFactory.InvalidArgument( _fileSystem.SafeFileHandleRegistry.Map(handle).Path); } +#endif private IStorageContainer GetContainer(SafeFileHandle handle, FileAccess? required) - => Resolve(handle, required).Container; - - private (IStorageContainer Container, FileMode Mode) Resolve( - SafeFileHandle handle, FileAccess? required) { - (IStorageContainer container, FileAccess access, FileMode mode) = ResolveEntry(handle); - + (IStorageContainer container, FileAccess access) = ResolveEntry(handle); if (required is { } requiredAccess && !access.HasFlag(requiredAccess)) { throw ExceptionFactory.AccessToPathDenied(); } - return (container, mode); + return container; } - private (IStorageContainer Container, FileAccess Access, FileMode Mode) ResolveEntry( - SafeFileHandle handle) + private (IStorageContainer Container, FileAccess Access) ResolveEntry(SafeFileHandle handle) { if (handle is null) { @@ -260,7 +256,7 @@ private int ReadInto(SafeFileHandle handle, Span buffer, long fileOffset) { if (fileOffset < 0) { - throw ExceptionFactory.NonNegativeNumberRequired("fileOffset"); + throw ExceptionFactory.NonNegativeNumberRequired(nameof(fileOffset)); } IStorageContainer container = GetContainer(handle, FileAccess.Read); @@ -283,7 +279,7 @@ private long ReadInto(SafeFileHandle handle, IReadOnlyList> buffers { if (fileOffset < 0) { - throw ExceptionFactory.NonNegativeNumberRequired("fileOffset"); + throw ExceptionFactory.NonNegativeNumberRequired(nameof(fileOffset)); } IStorageContainer container = GetContainer(handle, FileAccess.Read); @@ -312,35 +308,32 @@ private void WriteBytes(SafeFileHandle handle, byte[] buffer, long fileOffset) { if (fileOffset < 0) { - throw ExceptionFactory.NonNegativeNumberRequired("fileOffset"); + throw ExceptionFactory.NonNegativeNumberRequired(nameof(fileOffset)); } - (IStorageContainer container, FileMode mode) = Resolve(handle, FileAccess.Write); + IStorageContainer container = GetContainer(handle, FileAccess.Write); if (buffer.Length == 0) { return; } + // The content is a single array, so an offset beyond its maximum length cannot be written, just like one + // beyond the maximum file size of a real file system. + if (fileOffset > Array.MaxLength - buffer.Length) + { + throw ExceptionFactory.FileTooLarge( + _fileSystem.SafeFileHandleRegistry.Map(handle).Path); + } + // `RandomAccess` permits concurrent writes at distinct offsets, which would lose each other if two of them // started from the same snapshot. lock (Gate(container)) { - byte[] bytes = container.GetBytes(); - long required = fileOffset + buffer.Length; - if (required > bytes.Length) - { - byte[] grown = new byte[required]; - Array.Copy(bytes, grown, bytes.Length); - bytes = grown; - } - - Array.Copy(buffer, 0, bytes, fileOffset, buffer.Length); - container.WriteBytes(bytes); + container.WriteRange(buffer, fileOffset); } } private object Gate(IStorageContainer container) => _gates.GetOrCreateValue(container); - } #endif diff --git a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs index f46330f25..0d3bf508a 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs @@ -69,6 +69,9 @@ internal static DirectoryNotFoundException DirectoryNotFound(string? path = null #endif }; + internal static ArgumentOutOfRangeException EnumValueOutOfRange(string paramName) + => new(paramName, "Enum value was out of legal range."); + internal static IOException FileAlreadyExists(string path, int hResult) => new($"The file '{path}' already exists.", hResult); @@ -102,8 +105,8 @@ internal static IOException FileSharingViolation(string path) #endif }; - internal static ArgumentOutOfRangeException NonNegativeNumberRequired(string paramName) - => new(paramName, "Non-negative number required."); + internal static IOException FileTooLarge(string path) + => new($"File too large : '{path}'"); internal static ObjectDisposedException HandleIsClosed() => new("SafeHandle", "Safe handle has been closed."); @@ -116,9 +119,6 @@ internal static InternalBufferOverflowException InternalBufferOverflowException( => new( $"The internal buffer is greater than the {internalBufferSize} allowed bytes (~ {messages} messages)."); - internal static IOException InvalidArgument(string path) - => new($"Invalid argument : '{path}'"); - internal static ArgumentException InvalidAccessCombination( FileMode mode, FileAccess access) => new($"Combining FileMode: {mode} with FileAccess: {access} is invalid.", @@ -129,6 +129,9 @@ internal static ArgumentException InvalidAccessCombination( #endif }; + internal static IOException InvalidArgument(string path) + => new($"Invalid argument : '{path}'"); + internal static IOException InvalidDirectoryName(string path) => new($"The directory name is invalid: '{path}'") { @@ -153,6 +156,9 @@ internal static IOException MoveSourceMustBeDifferentThanDestination() internal static IOException NetworkPathNotFound(string path) => new($"The network path was not found. : '{path}'"); + internal static ArgumentOutOfRangeException NonNegativeNumberRequired(string paramName) + => new(paramName, "Non-negative number required."); + internal static IOException NotEnoughDiskSpace(string name) => new($"There is not enough space on the disk: '{name}'"); @@ -237,6 +243,14 @@ internal static ArgumentException PathIsEmpty(string paramName, #endif }; + internal static ArgumentException PreallocationRequiresNewFile(FileMode mode) + => new($"Preallocation size can be requested only for new files. Mode was '{mode}'.", + nameof(mode)); + + internal static ArgumentException PreallocationRequiresWriteAccess(FileAccess access) + => new($"Preallocation size can be requested only for write access. Access was '{access}'.", + nameof(access)); + internal static IOException ProcessCannotAccessTheFile(string path, int hResult) => new( $"The process cannot access the file '{path}' because it is being used by another process.", diff --git a/Source/Testably.Abstractions.Testing/MockFileSystem.cs b/Source/Testably.Abstractions.Testing/MockFileSystem.cs index 56383106c..0beda279d 100644 --- a/Source/Testably.Abstractions.Testing/MockFileSystem.cs +++ b/Source/Testably.Abstractions.Testing/MockFileSystem.cs @@ -99,17 +99,7 @@ internal ISafeFileHandleStrategy SafeFileHandleStrategy /// /// The underlying storage of directories and files. /// - internal IStorage Storage - { - get - { -#if FEATURE_FILESYSTEM_RANDOMACCESS - // Sweeping here means every file system operation observes handles the caller has since closed. - SafeFileHandleRegistry.ReleaseClosedHandles(); -#endif - return _storage; - } - } + internal IStorage Storage => _storage; /// /// The registered containers in the in-Memory . @@ -164,7 +154,6 @@ public MockFileSystem(Func options _pathMock = new PathMock(this); _storage = new InMemoryStorage(this); #if FEATURE_FILESYSTEM_RANDOMACCESS - // Created before anything can reach `Storage`, which sweeps it. SafeFileHandleRegistry = new MockSafeFileHandleRegistry(this); #endif ChangeHandler = new ChangeHandler(this, initialization.RecordNotificationHistory); diff --git a/Source/Testably.Abstractions.Testing/Storage/IStorage.cs b/Source/Testably.Abstractions.Testing/Storage/IStorage.cs index ba6f1e28f..f1f6fc19e 100644 --- a/Source/Testably.Abstractions.Testing/Storage/IStorage.cs +++ b/Source/Testably.Abstractions.Testing/Storage/IStorage.cs @@ -112,13 +112,6 @@ IEnumerable EnumerateLocations( [return: NotNullIfNotNull("path")] IStorageLocation? GetLocation(string? path, string? friendlyName = null); - /// - /// Returns the location that is currently registered under, or - /// if it is no longer registered. A container survives a rename, so anything holding - /// on to one cannot rely on the location it was opened at. - /// - IStorageLocation? GetLocation(IStorageContainer container); - /// /// Returns the drives that are present. /// diff --git a/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs b/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs index 164aaafba..ed617f590 100644 --- a/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs +++ b/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs @@ -196,6 +196,9 @@ public IEnumerable EnumerateLocations( EnumerationOptions? enumerationOptions = null) { ValidateExpression(searchPattern); +#if FEATURE_FILESYSTEM_RANDOMACCESS + _fileSystem.SafeFileHandleRegistry.ReleaseClosedHandles(); +#endif if (!_containers.TryGetValue(location, out IStorageContainer? parentContainer)) { throw ExceptionFactory.DirectoryNotFound(location.FullPath); @@ -318,6 +321,9 @@ private IEnumerable EnumerateLocationsImpl( return null; } +#if FEATURE_FILESYSTEM_RANDOMACCESS + _fileSystem.SafeFileHandleRegistry.ReleaseClosedHandles(); +#endif if (_containers.TryGetValue(location, out IStorageContainer? container)) { return container; @@ -684,6 +690,9 @@ public bool TryGetFileAccess( bool ignoreFileShare, [NotNullWhen(true)] out FileHandle? fileHandle) { +#if FEATURE_FILESYSTEM_RANDOMACCESS + _fileSystem.SafeFileHandleRegistry.ReleaseClosedHandles(); +#endif if (CanGetAccess(location, access, share, deleteAccess, ignoreFileShare)) { Guid guid = Guid.NewGuid(); @@ -769,20 +778,6 @@ internal IReadOnlyList GetContainers() .Select(x => x.Value) .ToList(); - /// - public IStorageLocation? GetLocation(IStorageContainer container) - { - foreach (KeyValuePair item in _containers) - { - if (ReferenceEquals(item.Value, container)) - { - return item.Key; - } - } - - return null; - } - /// /// Removes the drive with the given . /// diff --git a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/MockSafeFileHandleTests.cs b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/MockSafeFileHandleTests.cs new file mode 100644 index 000000000..0d0fea968 --- /dev/null +++ b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/MockSafeFileHandleTests.cs @@ -0,0 +1,157 @@ +#if FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System.IO; +using System.Runtime.CompilerServices; +using Testably.Abstractions.Testing.FileSystem; + +namespace Testably.Abstractions.Testing.Tests.FileSystem; + +public class MockSafeFileHandleTests +{ + [Test] + public async Task DeleteOnClose_OnUnix_WhenADirectoryTookTheName_ShouldNotThrowFromAnUnrelatedCall() + { + MockFileSystem fileSystem = new(o => o.SimulatingOperatingSystem(SimulationMode.Linux)); + + SafeFileHandle handle = fileSystem.File.OpenHandle("/y", FileMode.Create, + FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, FileOptions.DeleteOnClose); + fileSystem.File.Delete("/y"); + fileSystem.Directory.CreateDirectory("/y"); + handle.Dispose(); + + void Act() => fileSystem.File.Exists("/unrelated.txt"); + + await That(Act).DoesNotThrow() + .Because("a failed unlink at close is ignored, as it is on a real file system"); + await That(fileSystem.Directory.Exists("/y")).IsTrue(); + } + + [Test] + public async Task DeleteOnClose_OnUnix_WhenTheParentIsGone_ShouldNotThrowFromAnUnrelatedCall() + { + MockFileSystem fileSystem = new(o => o.SimulatingOperatingSystem(SimulationMode.Linux)); + fileSystem.Directory.CreateDirectory("/sub"); + + SafeFileHandle handle = fileSystem.File.OpenHandle("/sub/x", FileMode.Create, + FileAccess.Write, FileShare.ReadWrite | FileShare.Delete, FileOptions.DeleteOnClose); + fileSystem.File.Delete("/sub/x"); + fileSystem.Directory.Delete("/sub"); + handle.Dispose(); + + void Act() => fileSystem.File.Exists("/unrelated.txt"); + + await That(Act).DoesNotThrow() + .Because("a failed unlink at close is ignored, as it is on a real file system"); + } + + [Test] + public async Task DeleteOnClose_OnWindows_WhenAStreamStillHoldsTheFile_ShouldDeleteOnTheLastClose() + { + MockFileSystem fileSystem = new(o => o.SimulatingOperatingSystem(SimulationMode.Windows)); + fileSystem.File.WriteAllText("f.txt", "x"); + + SafeFileHandle handle = fileSystem.File.OpenHandle("f.txt", + FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite, FileOptions.DeleteOnClose); + FileSystemStream stream = fileSystem.File.Open("f.txt", + FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + handle.Dispose(); + + await That(fileSystem.File.Exists("f.txt")).IsTrue() + .Because("a stream still holds the file"); + + stream.Dispose(); + + await That(fileSystem.File.Exists("f.txt")).IsFalse() + .Because("the file is removed once the last holder releases it"); + } + + [Test] + public async Task + DeleteOnClose_OnWindows_WhenAStreamStillHoldsTheFile_ShouldNotThrowFromAnUnrelatedCall() + { + MockFileSystem fileSystem = new(o => o.SimulatingOperatingSystem(SimulationMode.Windows)); + fileSystem.File.WriteAllText("f.txt", "x"); + + SafeFileHandle handle = fileSystem.File.OpenHandle("f.txt", + FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite, FileOptions.DeleteOnClose); + using FileSystemStream stream = fileSystem.File.Open("f.txt", + FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + handle.Dispose(); + + void Act() => fileSystem.File.Exists("unrelated.txt"); + + await That(Act).DoesNotThrow() + .Because("a pending deletion must not surface from an unrelated call"); + } + + [Test] + public async Task FileStreamNew_WithNullHandle_ShouldThrowArgumentNullException() + { + MockFileSystem fileSystem = new(); + + void Act() => fileSystem.FileStream.New((SafeFileHandle)null!, FileAccess.Read); + + await That(Act).Throws(); + } + + [Test] + public async Task OpenHandle_WhenNeverDisposed_ShouldReleaseTheShareLockOnceCollected() + { + MockFileSystem fileSystem = new(); + fileSystem.File.WriteAllText("f.txt", "x"); + + OpenAndDrop(fileSystem, "f.txt"); + GC.Collect(); + GC.WaitForPendingFinalizers(); + + void Act() + { + using SafeFileHandle handle = fileSystem.File.OpenHandle("f.txt", + FileMode.Open, FileAccess.ReadWrite, FileShare.None); + } + + await That(Act).DoesNotThrow() + .Because("a handle that is no longer referenced is closed, as a real one would be when finalized"); + } + + [Test] + public async Task Write_AtAnOffsetThatOverflows_ShouldThrowIOException() + { + MockFileSystem fileSystem = new(); + fileSystem.File.WriteAllBytes("f.txt", [1,]); + + using SafeFileHandle handle = fileSystem.File.OpenHandle("f.txt", + FileMode.Open, FileAccess.Write); + + void Act() => fileSystem.RandomAccess.Write(handle, new byte[] { 9, }, long.MaxValue); + + await That(Act).Throws(); + } + + [Test] + public async Task Write_WhenAChangingInterceptionThrows_ShouldLeaveTheContentUnchanged() + { + MockFileSystem fileSystem = new(); + fileSystem.File.WriteAllBytes("f.txt", [1, 2, 3,]); + + using (fileSystem.Intercept.Changing(FileSystemTypes.File, + _ => throw new InvalidOperationException("vetoed"))) + { + using SafeFileHandle handle = fileSystem.File.OpenHandle("f.txt", + FileMode.Open, FileAccess.Write, FileShare.ReadWrite); + + void Act() => fileSystem.RandomAccess.Write(handle, new byte[] { 9, }, 0); + + await That(Act).Throws(); + } + + await That(fileSystem.File.ReadAllBytes("f.txt")) + .IsEqualTo(new byte[] { 1, 2, 3, }) + .Because("the write was vetoed before it was published"); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void OpenAndDrop(MockFileSystem fileSystem, string path) + => _ = fileSystem.File.OpenHandle(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); +} +#endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/File/OpenHandleTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/File/OpenHandleTests.cs index 6cfd7b4fb..c6a7b9570 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/File/OpenHandleTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/File/OpenHandleTests.cs @@ -152,6 +152,126 @@ void Act() await That(Act).Throws(); } + [Test] + public async Task OpenHandle_WithNullPath_ShouldThrowArgumentNullException() + { + void Act() + { + using SafeFileHandle handle = + FileSystem.File.OpenHandle(null!, FileMode.Create, FileAccess.Read); + } + + await That(Act).Throws() + .Because("the path is validated before the mode and access combination"); + } + + [Test] + public async Task OpenHandle_WithEmptyPath_ShouldThrowArgumentExceptionForThePath() + { + void Act() + { + using SafeFileHandle handle = + FileSystem.File.OpenHandle("", FileMode.Create, FileAccess.Read); + } + + await That(Act).Throws().WithParamName("path"); + } + + [Test] + public async Task + OpenHandle_WithNullPathAndNegativePreallocationSize_ShouldThrowArgumentNullException() + { + void Act() + { + using SafeFileHandle handle = FileSystem.File.OpenHandle(null!, + FileMode.Create, FileAccess.Write, FileShare.None, FileOptions.None, -1); + } + + await That(Act).Throws(); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WithInvalidFileMode_ShouldThrowArgumentOutOfRangeException( + string path) + { + FileSystem.File.WriteAllText(path, "x"); + + void Act() + { + using SafeFileHandle handle = + FileSystem.File.OpenHandle(path, (FileMode)99, FileAccess.Read); + } + + await That(Act).Throws().WithParamName("mode"); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WithInvalidFileAccess_ShouldThrowArgumentOutOfRangeException( + string path) + { + FileSystem.File.WriteAllText(path, "x"); + + void Act() + { + using SafeFileHandle handle = + FileSystem.File.OpenHandle(path, FileMode.Open, (FileAccess)99); + } + + await That(Act).Throws().WithParamName("access"); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WithInvalidFileShare_ShouldThrowArgumentOutOfRangeException( + string path) + { + FileSystem.File.WriteAllText(path, "x"); + + void Act() + { + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Read, (FileShare)99); + } + + await That(Act).Throws().WithParamName("share"); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_WithPreallocationSizeOnExistingFile_ShouldThrowArgumentException( + string path) + { + FileSystem.File.WriteAllText(path, "x"); + + void Act() + { + using SafeFileHandle handle = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.Write, FileShare.None, FileOptions.None, 100); + } + + await That(Act).Throws().WithParamName("mode") + .Because("a preallocation size can only be requested for new files"); + } + + [Test] + [AutoArguments] + public async Task OpenHandle_OnADirectory_ShouldThrowUnauthorizedAccessException(string path) + { + Skip.IfNot(Test.RunsOnWindows); + + FileSystem.Directory.CreateDirectory(path); + + void Act() + { + using SafeFileHandle handle = + FileSystem.File.OpenHandle(path, FileMode.Open, FileAccess.Read); + } + + await That(Act).Throws(); + } + [Test] [AutoArguments] public async Task OpenHandle_WhenDisposed_ShouldBeClosed(string path, string contents) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs index c789965f8..2ef0b2cf9 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs @@ -249,29 +249,5 @@ public async Task SetUnixFileMode_ShouldChangeUnixFileMode(string path, string c } #endif - [Test] - [AutoArguments] - public async Task AllHandleOverloads_ShouldAgreeWithThePathOverloads( - string path, string contents) - { - FileSystem.File.WriteAllText(path, contents); - - using SafeFileHandle handle = FileSystem.File.OpenHandle(path); - - await That(FileSystem.File.GetAttributes(handle)) - .IsEqualTo(FileSystem.File.GetAttributes(path)); - await That(FileSystem.File.GetCreationTime(handle)) - .IsEqualTo(FileSystem.File.GetCreationTime(path)); - await That(FileSystem.File.GetCreationTimeUtc(handle)) - .IsEqualTo(FileSystem.File.GetCreationTimeUtc(path)); - await That(FileSystem.File.GetLastAccessTime(handle)) - .IsEqualTo(FileSystem.File.GetLastAccessTime(path)); - await That(FileSystem.File.GetLastAccessTimeUtc(handle)) - .IsEqualTo(FileSystem.File.GetLastAccessTimeUtc(path)); - await That(FileSystem.File.GetLastWriteTime(handle)) - .IsEqualTo(FileSystem.File.GetLastWriteTime(path)); - await That(FileSystem.File.GetLastWriteTimeUtc(handle)) - .IsEqualTo(FileSystem.File.GetLastWriteTimeUtc(path)); - } } #endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs index ef6789e08..c6686e2e9 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs @@ -71,12 +71,37 @@ await That(FileSystem.File.Exists(path)).IsFalse() .Because("unlinking the name does not wait for other handles"); } + [Test] + [AutoArguments] + public async Task DeleteOnClose_OnWindows_ShouldDeleteOnlyWhenTheLastHandleIsClosed(string path) + { + Skip.IfNot(Test.RunsOnWindows, "Unix unlinks the name as soon as that handle closes"); + + FileSystem.File.WriteAllText(path, null); + + SafeFileHandle first = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete, + FileOptions.DeleteOnClose); + SafeFileHandle second = FileSystem.File.OpenHandle(path, + FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete); + + first.Dispose(); + + await That(FileSystem.File.Exists(path)).IsTrue() + .Because("a second handle is still open on the file"); + + second.Dispose(); + + await That(FileSystem.File.Exists(path)).IsFalse(); + } + [Test] [AutoArguments] public async Task DeleteOnClose_OnUnix_ShouldNotFollowTheFile_WhenItIsRenamed( string path, string other) { - Skip.If(Test.RunsOnWindows, "Windows follows the file across a rename"); + Skip.If(Test.RunsOnWindows, + "moving a file that a handle holds open needs `ignoreFileShare`, which is inert on Windows: see #1086"); FileSystem.File.WriteAllText(path, null); @@ -96,7 +121,8 @@ await That(FileSystem.File.Exists(other)).IsTrue() public async Task DeleteOnClose_OnUnix_ShouldDeleteAReplacementAtTheOldPath( string path, string other) { - Skip.If(Test.RunsOnWindows, "Windows follows the file across a rename"); + Skip.If(Test.RunsOnWindows, + "moving a file that a handle holds open needs `ignoreFileShare`, which is inert on Windows: see #1086"); FileSystem.File.WriteAllText(path, null); From d77a29cbd361c329452ab9db49afdd18edd5bcc0 Mon Sep 17 00:00:00 2001 From: mpdreamz Date: Fri, 25 Sep 2026 20:23:39 +0200 Subject: [PATCH 15/17] test: enforce statistics coverage for `IRandomAccess` `IRandomAccess` joins the data-driven list in `StatisticsTests`, with a registration test for every member. Co-Authored-By: Claude Opus 5.5 --- .../FileSystem/RandomAccessStatisticsTests.cs | 178 ++++++++++++++++++ .../Statistics/StatisticsTests.cs | 4 + 2 files changed, 182 insertions(+) create mode 100644 Tests/Testably.Abstractions.Testing.Tests/Statistics/FileSystem/RandomAccessStatisticsTests.cs diff --git a/Tests/Testably.Abstractions.Testing.Tests/Statistics/FileSystem/RandomAccessStatisticsTests.cs b/Tests/Testably.Abstractions.Testing.Tests/Statistics/FileSystem/RandomAccessStatisticsTests.cs new file mode 100644 index 000000000..d0dff3e7f --- /dev/null +++ b/Tests/Testably.Abstractions.Testing.Tests/Statistics/FileSystem/RandomAccessStatisticsTests.cs @@ -0,0 +1,178 @@ +#if FEATURE_FILESYSTEM_RANDOMACCESS +using Microsoft.Win32.SafeHandles; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using Testably.Abstractions.Testing.Tests.TestHelpers; + +namespace Testably.Abstractions.Testing.Tests.Statistics.FileSystem; + +public class RandomAccessStatisticsTests +{ +#if FEATURE_RANDOMACCESS_FLUSHTODISK + [Test] + public async Task Method_FlushToDisk_SafeFileHandle_ShouldRegisterCall() + { + MockFileSystem sut = new(); + using SafeFileHandle handle = OpenHandle(sut); + + sut.RandomAccess.FlushToDisk(handle); + + await That(sut.Statistics.RandomAccess).OnlyContainsMethodCall( + nameof(IRandomAccess.FlushToDisk), handle); + } +#endif + + [Test] + public async Task Method_GetLength_SafeFileHandle_ShouldRegisterCall() + { + MockFileSystem sut = new(); + using SafeFileHandle handle = OpenHandle(sut); + + _ = sut.RandomAccess.GetLength(handle); + + await That(sut.Statistics.TotalCount).IsEqualTo(2); + await That(sut.Statistics.RandomAccess).OnlyContainsMethodCall( + nameof(IRandomAccess.GetLength), handle); + } + + [Test] + public async Task Method_Read_SafeFileHandle_IReadOnlyListMemoryByte_Int64_ShouldRegisterCall() + { + MockFileSystem sut = new(); + using SafeFileHandle handle = OpenHandle(sut); + IReadOnlyList> buffers = [new byte[1],]; + long fileOffset = 0; + + _ = sut.RandomAccess.Read(handle, buffers, fileOffset); + + await That(sut.Statistics.RandomAccess).OnlyContainsMethodCall( + nameof(IRandomAccess.Read), handle, buffers, fileOffset); + } + + [Test] + public async Task Method_Read_SafeFileHandle_SpanByte_Int64_ShouldRegisterCall() + { + MockFileSystem sut = new(); + using SafeFileHandle handle = OpenHandle(sut); + Span buffer = new byte[1]; + long fileOffset = 0; + + _ = sut.RandomAccess.Read(handle, buffer, fileOffset); + + await That(sut.Statistics.RandomAccess).OnlyContainsMethodCall( + nameof(IRandomAccess.Read), handle, (ReadOnlySpan)buffer, fileOffset); + } + + [Test] + public async Task + Method_ReadAsync_SafeFileHandle_IReadOnlyListMemoryByte_Int64_CancellationToken_ShouldRegisterCall() + { + MockFileSystem sut = new(); + using SafeFileHandle handle = OpenHandle(sut); + IReadOnlyList> buffers = [new byte[1],]; + long fileOffset = 0; + CancellationToken cancellationToken = CancellationToken.None; + + _ = await sut.RandomAccess.ReadAsync(handle, buffers, fileOffset, cancellationToken); + + await That(sut.Statistics.RandomAccess).OnlyContainsMethodCall( + nameof(IRandomAccess.ReadAsync), handle, buffers, fileOffset, cancellationToken); + } + + [Test] + public async Task + Method_ReadAsync_SafeFileHandle_MemoryByte_Int64_CancellationToken_ShouldRegisterCall() + { + MockFileSystem sut = new(); + using SafeFileHandle handle = OpenHandle(sut); + Memory buffer = new byte[1]; + long fileOffset = 0; + CancellationToken cancellationToken = CancellationToken.None; + + _ = await sut.RandomAccess.ReadAsync(handle, buffer, fileOffset, cancellationToken); + + await That(sut.Statistics.RandomAccess).OnlyContainsMethodCall( + nameof(IRandomAccess.ReadAsync), handle, buffer, fileOffset, cancellationToken); + } + +#if FEATURE_RANDOMACCESS_FLUSHTODISK + [Test] + public async Task Method_SetLength_SafeFileHandle_Int64_ShouldRegisterCall() + { + MockFileSystem sut = new(); + using SafeFileHandle handle = OpenHandle(sut); + long length = 2; + + sut.RandomAccess.SetLength(handle, length); + + await That(sut.Statistics.RandomAccess).OnlyContainsMethodCall( + nameof(IRandomAccess.SetLength), handle, length); + } +#endif + + [Test] + public async Task + Method_Write_SafeFileHandle_IReadOnlyListReadOnlyMemoryByte_Int64_ShouldRegisterCall() + { + MockFileSystem sut = new(); + using SafeFileHandle handle = OpenHandle(sut); + IReadOnlyList> buffers = [new byte[] { 1, },]; + long fileOffset = 0; + + sut.RandomAccess.Write(handle, buffers, fileOffset); + + await That(sut.Statistics.RandomAccess).OnlyContainsMethodCall( + nameof(IRandomAccess.Write), handle, buffers, fileOffset); + } + + [Test] + public async Task Method_Write_SafeFileHandle_ReadOnlySpanByte_Int64_ShouldRegisterCall() + { + MockFileSystem sut = new(); + using SafeFileHandle handle = OpenHandle(sut); + ReadOnlySpan buffer = new byte[] { 1, }; + long fileOffset = 0; + + sut.RandomAccess.Write(handle, buffer, fileOffset); + + await That(sut.Statistics.RandomAccess).OnlyContainsMethodCall( + nameof(IRandomAccess.Write), handle, buffer, fileOffset); + } + + [Test] + public async Task + Method_WriteAsync_SafeFileHandle_IReadOnlyListReadOnlyMemoryByte_Int64_CancellationToken_ShouldRegisterCall() + { + MockFileSystem sut = new(); + using SafeFileHandle handle = OpenHandle(sut); + IReadOnlyList> buffers = [new byte[] { 1, },]; + long fileOffset = 0; + CancellationToken cancellationToken = CancellationToken.None; + + await sut.RandomAccess.WriteAsync(handle, buffers, fileOffset, cancellationToken); + + await That(sut.Statistics.RandomAccess).OnlyContainsMethodCall( + nameof(IRandomAccess.WriteAsync), handle, buffers, fileOffset, cancellationToken); + } + + [Test] + public async Task + Method_WriteAsync_SafeFileHandle_ReadOnlyMemoryByte_Int64_CancellationToken_ShouldRegisterCall() + { + MockFileSystem sut = new(); + using SafeFileHandle handle = OpenHandle(sut); + ReadOnlyMemory buffer = new byte[] { 1, }; + long fileOffset = 0; + CancellationToken cancellationToken = CancellationToken.None; + + await sut.RandomAccess.WriteAsync(handle, buffer, fileOffset, cancellationToken); + + await That(sut.Statistics.RandomAccess).OnlyContainsMethodCall( + nameof(IRandomAccess.WriteAsync), handle, buffer, fileOffset, cancellationToken); + } + + private static SafeFileHandle OpenHandle(MockFileSystem sut) + => sut.File.OpenHandle("foo", FileMode.OpenOrCreate, FileAccess.ReadWrite); +} +#endif diff --git a/Tests/Testably.Abstractions.Testing.Tests/Statistics/StatisticsTests.cs b/Tests/Testably.Abstractions.Testing.Tests/Statistics/StatisticsTests.cs index 85e924453..ad3e43744 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/Statistics/StatisticsTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/Statistics/StatisticsTests.cs @@ -54,6 +54,10 @@ public async Task FileSystem_Initialize_ShouldNotRegisterStatistics() typeof(IFileVersionInfo), typeof(FileVersionInfoStatisticsTests))] [Arguments(nameof(MockFileSystem.Path), false, typeof(IPath), typeof(FileSystem.PathStatisticsTests))] +#if FEATURE_FILESYSTEM_RANDOMACCESS + [Arguments(nameof(MockFileSystem.RandomAccess), false, + typeof(IRandomAccess), typeof(RandomAccessStatisticsTests))] +#endif public async Task ShouldHaveTestedAllFileSystemMethods(string className, bool requireInstance, Type mockType, Type testType) { From cbcdf1000324e25ac232077b49b96a9ca8a24c73 Mon Sep 17 00:00:00 2001 From: mpdreamz Date: Fri, 25 Sep 2026 20:23:50 +0200 Subject: [PATCH 16/17] docs: describe when a closed handle takes effect Windows no longer claims that a delete-on-close follows a renamed file, and the page says when a closed or dropped handle is noticed. Co-Authored-By: Claude Opus 5.5 --- Docs/pages/docs/file-system/safe-file-handles.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Docs/pages/docs/file-system/safe-file-handles.mdx b/Docs/pages/docs/file-system/safe-file-handles.mdx index d49274a97..af7c8213b 100644 --- a/Docs/pages/docs/file-system/safe-file-handles.mdx +++ b/Docs/pages/docs/file-system/safe-file-handles.mdx @@ -24,9 +24,9 @@ await Expect.That(fileSystem.RandomAccess.GetLength(handle)).IsEqualTo(12); The handle refers to the file it was opened on rather than to its name, so reads and writes keep working after the file is renamed, and do not start addressing whatever is later created under the original path. -It also carries the file share and the `FileOptions` it was opened with. `FileOptions.DeleteOnClose` follows the platform: on Unix the name that was opened is unlinked as soon as that handle closes, whatever else still holds the file open, so a file renamed in the meantime survives and a replacement under the old name does not; on Windows the file is removed once the last handle closes, and the deletion follows the file across a rename. +It also carries the file share and the `FileOptions` it was opened with. `FileOptions.DeleteOnClose` follows the platform: on Unix the name that was opened is unlinked as soon as that handle closes, whatever else still holds the file open, so a file renamed in the meantime survives and a replacement under the old name does not; on Windows the file is removed once nothing holds it open any more, streams included. -Because `SafeFileHandle` is sealed, the mock cannot be notified when one is closed, so released share locks and `DeleteOnClose` take effect on the next file system operation rather than at the moment of disposal. +Because `SafeFileHandle` is sealed, the mock cannot be notified when one is closed, so released share locks and `DeleteOnClose` take effect the next time the file system is asked whether a file exists or may be opened, rather than at the moment of disposal. A handle that is dropped without being disposed releases its share lock once it is garbage collected, as a real handle does when it is finalized. A handle the `MockFileSystem` created is not a real OS handle, so passing it to `System.IO.RandomAccess` or to another process fails. Use `IFileSystem.RandomAccess` and the `IFile` overloads instead, which is the point of the abstraction. From 3b4754dbde58e43cc7a4c983b573eacec2a95c63 Mon Sep 17 00:00:00 2001 From: mpdreamz Date: Fri, 25 Sep 2026 22:01:38 +0200 Subject: [PATCH 17/17] fix: record `RandomAccess` span buffers on .NET 6 and 8 The `(T1, ReadOnlySpan, T3)` registration overload was compiled only with `FEATURE_FILE_SPAN` (.NET 9+), so on .NET 6 and 8 the buffers bound to the plain generic overload, which cannot take a span. It is a generic helper with nothing file-specific about it, so it now needs only `FEATURE_SPAN`, like its counterpart in the test helpers. Co-Authored-By: Claude Opus 5.5 --- .../FileSystem/RandomAccessMock.cs | 4 ++-- .../Statistics/CallStatistics.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs index 96581e5ec..baf936eb6 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs @@ -55,7 +55,7 @@ public long GetLength(SafeFileHandle handle) public int Read(SafeFileHandle handle, Span buffer, long fileOffset) { using IDisposable registration = _fileSystem.StatisticsRegistration - .RandomAccess.RegisterMethod(nameof(Read), handle, buffer, fileOffset); + .RandomAccess.RegisterMethod(nameof(Read), handle, buffer, fileOffset); return ReadInto(handle, buffer, fileOffset); } @@ -133,7 +133,7 @@ public void SetLength(SafeFileHandle handle, long length) public void Write(SafeFileHandle handle, ReadOnlySpan buffer, long fileOffset) { using IDisposable registration = _fileSystem.StatisticsRegistration - .RandomAccess.RegisterMethod(nameof(Write), handle, buffer, fileOffset); + .RandomAccess.RegisterMethod(nameof(Write), handle, buffer, fileOffset); WriteBytes(handle, buffer.ToArray(), fileOffset); } diff --git a/Source/Testably.Abstractions.Testing/Statistics/CallStatistics.cs b/Source/Testably.Abstractions.Testing/Statistics/CallStatistics.cs index 55096993d..c23e7d40e 100644 --- a/Source/Testably.Abstractions.Testing/Statistics/CallStatistics.cs +++ b/Source/Testably.Abstractions.Testing/Statistics/CallStatistics.cs @@ -244,7 +244,7 @@ internal IDisposable RegisterMethod(string name, T1 parameter1, ReadOnly } #endif -#if FEATURE_FILE_SPAN +#if FEATURE_SPAN /// /// Registers the method with , and . ///