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/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..af7c8213b 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 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 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.
+
+## 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`.
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/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..ce1ed9c2e 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,11 @@ public FileSystemStream Wrap(FileStream fileStream)
}
#endregion
+
+ 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..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,27 +863,4 @@ private void ThrowIfDisposed()
throw new ObjectDisposedException("", "Cannot access a closed file.");
}
}
-
- 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);
- }
- }
}
diff --git a/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs
new file mode 100644
index 000000000..a38e0ca3f
--- /dev/null
+++ b/Source/Testably.Abstractions.Testing/FileSystem/MockSafeFileHandleRegistry.cs
@@ -0,0 +1,347 @@
+#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 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
+ private readonly System.Threading.Lock _lock = new();
+#else
+ private readonly object _lock = new();
+#endif
+ ///
+ /// Deletions waiting for the last handle to the file to close.
+ ///
+ private readonly List _pendingDeletes = [];
+
+ private volatile bool _hasWork;
+ private long _nextHandleValue = FirstHandleValue;
+ private bool _sweeping;
+
+ internal MockSafeFileHandleRegistry(MockFileSystem fileSystem)
+ {
+ _fileSystem = fileSystem;
+ }
+
+ internal SafeFileHandle Open(string path,
+ FileMode mode,
+ FileAccess access,
+ FileShare share,
+ FileOptions options,
+ long preallocationSize)
+ {
+ 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 `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 is FileMode.Create or FileMode.Truncate)
+ {
+ container.WriteBytes([]);
+ }
+
+ lock (_lock)
+ {
+ IntPtr value = new(_nextHandleValue++);
+ SafeFileHandle handle = new(value, ownsHandle: false);
+ _entries[value] = new Entry(
+ new WeakReference(handle),
+ new SafeFileHandleMock(location.FullPath, mode, share),
+ accessLock,
+ location,
+ container,
+ access,
+ options);
+ _hasWork = true;
+ return 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) GetContainer(SafeFileHandle handle)
+ {
+ if (Resolve(handle) is { } entry)
+ {
+ return (entry.Container, entry.Access);
+ }
+
+ SafeFileHandleMock mock = MapForeign(handle);
+ IStorageContainer container = _fileSystem.Storage
+ .GetContainer(_fileSystem.Storage.GetLocation(mock.Path)
+ .ThrowExceptionIfNotFound(_fileSystem));
+ 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. 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)
+ {
+ return;
+ }
+
+ List released = [];
+ List closed = [];
+ lock (_lock)
+ {
+ if (_sweeping)
+ {
+ return;
+ }
+
+ foreach (KeyValuePair item in _entries)
+ {
+ if (!item.Value.Handle.TryGetTarget(out SafeFileHandle? handle) ||
+ handle.IsClosed)
+ {
+ closed.Add(item.Key);
+ released.Add(item.Value);
+ }
+ }
+
+ if (released.Count == 0 && _pendingDeletes.Count == 0)
+ {
+ return;
+ }
+
+ foreach (IntPtr value in closed)
+ {
+ _entries.Remove(value);
+ }
+
+ _sweeping = true;
+ }
+
+ try
+ {
+ foreach (Entry entry in released)
+ {
+ entry.AccessLock.Dispose();
+ 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, whatever else still holds
+ // the file open.
+ TryDelete(entry.Location);
+ }
+ }
+
+ // Windows removes the file once the last handle to it closes.
+ _pendingDeletes.RemoveAll(IsDeletedOrGone);
+ }
+ finally
+ {
+ lock (_lock)
+ {
+ _sweeping = false;
+ _hasWork = _entries.Count > 0 || _pendingDeletes.Count > 0;
+ }
+ }
+ }
+
+ 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)
+ {
+ 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.
+ long candidate = value.ToInt64();
+ if (candidate >= FirstHandleValue && candidate < _nextHandleValue)
+ {
+ throw ExceptionFactory.HandleIsClosed();
+ }
+
+ return null;
+ }
+ }
+
+ private void TryDelete(IStorageLocation location)
+ {
+ 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.
+ }
+ }
+
+ ///
+ /// 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)
+ {
+ path = path.EnsureValidFormat(_fileSystem, nameof(path), includeIsEmptyCheck: true);
+
+ if (mode is < FileMode.CreateNew or > FileMode.Append)
+ {
+ throw ExceptionFactory.EnumValueOutOfRange(nameof(mode));
+ }
+
+ if (access is < FileAccess.Read or > FileAccess.ReadWrite)
+ {
+ throw ExceptionFactory.EnumValueOutOfRange(nameof(access));
+ }
+
+ 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);
+
+ 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
new file mode 100644
index 000000000..baf936eb6
--- /dev/null
+++ b/Source/Testably.Abstractions.Testing/FileSystem/RandomAccessMock.cs
@@ -0,0 +1,339 @@
+#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;
+
+ private readonly System.Runtime.CompilerServices.ConditionalWeakTable
+ _gates = new();
+
+ 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);
+
+ // 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
+
+ ///
+ 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, buffer, fileOffset);
+
+ return ReadInto(handle, buffer, fileOffset);
+ }
+
+ ///
+ public long Read(SafeFileHandle handle, IReadOnlyList> buffers, long fileOffset)
+ {
+ using IDisposable registration = _fileSystem.StatisticsRegistration
+ .RandomAccess.RegisterMethod(nameof(Read), handle, buffers, fileOffset);
+
+ if (buffers is null)
+ {
+ throw new ArgumentNullException(nameof(buffers));
+ }
+
+ return ReadInto(handle, buffers, fileOffset);
+ }
+
+ ///
+ 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, buffer, 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, buffers, fileOffset,
+ cancellationToken);
+
+ return new ValueTask(Read(handle, buffers, fileOffset));
+ }
+
+#if FEATURE_RANDOMACCESS_FLUSHTODISK
+ ///
+ public void SetLength(SafeFileHandle handle, long length)
+ {
+ using IDisposable registration = _fileSystem.StatisticsRegistration
+ .RandomAccess.RegisterMethod(nameof(SetLength), handle, length);
+
+ if (length < 0)
+ {
+ throw ExceptionFactory.NonNegativeNumberRequired(nameof(length));
+ }
+
+ IStorageContainer container = GetContainerForResize(handle);
+ 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
+
+ ///
+ public void Write(SafeFileHandle handle, ReadOnlySpan buffer, long fileOffset)
+ {
+ using IDisposable registration = _fileSystem.StatisticsRegistration
+ .RandomAccess.RegisterMethod(nameof(Write), handle, buffer, 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, buffers, 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, buffer, 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, buffers, fileOffset,
+ cancellationToken);
+
+ Write(handle, buffers, fileOffset);
+ return default;
+ }
+
+ #endregion
+
+ 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;
+ }
+
+#if FEATURE_RANDOMACCESS_FLUSHTODISK
+ 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);
+ }
+#endif
+
+ private IStorageContainer GetContainer(SafeFileHandle handle, FileAccess? required)
+ {
+ (IStorageContainer container, FileAccess access) = ResolveEntry(handle);
+ if (required is { } requiredAccess && !access.HasFlag(requiredAccess))
+ {
+ throw ExceptionFactory.AccessToPathDenied();
+ }
+
+ return container;
+ }
+
+ private (IStorageContainer Container, FileAccess Access) ResolveEntry(SafeFileHandle handle)
+ {
+ if (handle is null)
+ {
+ throw new ArgumentNullException(nameof(handle));
+ }
+
+ if (handle.IsInvalid)
+ {
+ throw ExceptionFactory.HandleIsInvalid();
+ }
+
+ return _fileSystem.SafeFileHandleRegistry.GetContainer(handle);
+ }
+
+ private int ReadInto(SafeFileHandle handle, Span buffer, long fileOffset)
+ {
+ if (fileOffset < 0)
+ {
+ throw ExceptionFactory.NonNegativeNumberRequired(nameof(fileOffset));
+ }
+
+ IStorageContainer container = GetContainer(handle, FileAccess.Read);
+ lock (Gate(container))
+ {
+ 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;
+ }
+ }
+
+ private long ReadInto(SafeFileHandle handle, IReadOnlyList> buffers,
+ long fileOffset)
+ {
+ if (fileOffset < 0)
+ {
+ throw ExceptionFactory.NonNegativeNumberRequired(nameof(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;
+ }
+ }
+
+ private void WriteBytes(SafeFileHandle handle, byte[] buffer, long fileOffset)
+ {
+ if (fileOffset < 0)
+ {
+ throw ExceptionFactory.NonNegativeNumberRequired(nameof(fileOffset));
+ }
+
+ 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))
+ {
+ 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 5d0cf6488..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,6 +105,12 @@ internal static IOException FileSharingViolation(string path)
#endif
};
+ internal static IOException FileTooLarge(string path)
+ => new($"File too large : '{path}'");
+
+ internal static ObjectDisposedException HandleIsClosed()
+ => new("SafeHandle", "Safe handle has been closed.");
+
internal static ArgumentException HandleIsInvalid(string? paramName = "handle")
=> new("Invalid handle.", paramName);
@@ -120,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}'")
{
@@ -144,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}'");
@@ -228,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/Helpers/FileModeHelper.cs b/Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs
new file mode 100644
index 000000000..08f26a5e4
--- /dev/null
+++ b/Source/Testably.Abstractions.Testing/Helpers/FileModeHelper.cs
@@ -0,0 +1,78 @@
+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)
+ {
+ 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..0beda279d 100644
--- a/Source/Testably.Abstractions.Testing/MockFileSystem.cs
+++ b/Source/Testably.Abstractions.Testing/MockFileSystem.cs
@@ -84,6 +84,10 @@ internal IAccessControlStrategy AccessControlStrategy
internal FileSystemRegistration Registration { get; }
+#if FEATURE_FILESYSTEM_RANDOMACCESS
+ internal MockSafeFileHandleRegistry SafeFileHandleRegistry { get; }
+#endif
+
internal ISafeFileHandleStrategy SafeFileHandleStrategy
{
get;
@@ -114,6 +118,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 +153,9 @@ public MockFileSystem(Func options
TimeSystem = initialization.TimeSystem ?? new MockTimeSystem(TimeProviderFactory.Now());
_pathMock = new PathMock(this);
_storage = new InMemoryStorage(this);
+#if FEATURE_FILESYSTEM_RANDOMACCESS
+ SafeFileHandleRegistry = new MockSafeFileHandleRegistry(this);
+#endif
ChangeHandler = new ChangeHandler(this, initialization.RecordNotificationHistory);
_directoryMock = new DirectoryMock(this);
_fileMock = new FileMock(this);
@@ -156,6 +166,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 +208,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/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 .
///
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/Storage/InMemoryStorage.cs b/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs
index ffcc9ba1b..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();
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/Testably.Abstractions.Testing.Tests/FileSystem/ForeignSafeFileHandleTests.cs b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ForeignSafeFileHandleTests.cs
new file mode 100644
index 000000000..3e25c807c
--- /dev/null
+++ b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ForeignSafeFileHandleTests.cs
@@ -0,0 +1,66 @@
+#if FEATURE_FILESYSTEM_RANDOMACCESS
+using Microsoft.Win32.SafeHandles;
+using System.IO;
+using Testably.Abstractions.Testing.FileSystem;
+
+namespace Testably.Abstractions.Testing.Tests.FileSystem;
+
+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
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.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/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)
{
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..c6a7b9570
--- /dev/null
+++ b/Tests/Testably.Abstractions.Tests/FileSystem/File/OpenHandleTests.cs
@@ -0,0 +1,386 @@
+#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]
+ 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)
+ {
+ 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..2ef0b2cf9
--- /dev/null
+++ b/Tests/Testably.Abstractions.Tests/FileSystem/File/SafeFileHandleTests.cs
@@ -0,0 +1,253 @@
+#if FEATURE_FILESYSTEM_SAFEFILEHANDLE && FEATURE_FILESYSTEM_RANDOMACCESS
+using Microsoft.Win32.SafeHandles;
+using System.IO;
+
+namespace Testably.Abstractions.Tests.FileSystem.File;
+
+[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
+
+}
+#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..2d7754460
--- /dev/null
+++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileStreamFactory/OpenHandleStreamTests.cs
@@ -0,0 +1,87 @@
+#if FEATURE_FILESYSTEM_SAFEFILEHANDLE && FEATURE_FILESYSTEM_RANDOMACCESS
+using Microsoft.Win32.SafeHandles;
+using System.IO;
+
+namespace Testably.Abstractions.Tests.FileSystem.FileStreamFactory;
+
+[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..53fd5feff
--- /dev/null
+++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/AppendTests.cs
@@ -0,0 +1,57 @@
+#if FEATURE_FILESYSTEM_RANDOMACCESS
+using Microsoft.Win32.SafeHandles;
+using System.IO;
+
+namespace Testably.Abstractions.Tests.FileSystem.RandomAccess;
+
+[FileSystemTests]
+public class AppendTests(FileSystemTestData testData) : FileSystemTestBase(testData)
+{
+ [Test]
+ [AutoArguments]
+ public async Task Write_OnAppendHandle_ShouldHonourTheOffset(string path)
+ {
+ 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_AtTheLength_ShouldExtendTheFile(string path)
+ {
+ 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, }, 4);
+ }
+
+ await That(FileSystem.File.ReadAllBytes(path))
+ .IsEqualTo(new byte[] { 1, 2, 3, 4, 9, });
+ }
+
+ [Test]
+ [AutoArguments]
+ public async Task Write_OnAppendHandle_ToAnEmptyFile_ShouldWriteFromTheOffset(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..c6686e2e9
--- /dev/null
+++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/HandleIdentityTests.cs
@@ -0,0 +1,141 @@
+#if FEATURE_FILESYSTEM_RANDOMACCESS
+using Microsoft.Win32.SafeHandles;
+using System.IO;
+
+namespace Testably.Abstractions.Tests.FileSystem.RandomAccess;
+
+[FileSystemTests]
+public class HandleIdentityTests(FileSystemTestData testData) : FileSystemTestBase(testData)
+{
+ [Test]
+ [AutoArguments]
+ public async Task Handle_ShouldKeepWorking_WhenTheFileIsRenamed(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");
+
+ 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,
+ "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,]);
+
+ 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_OnUnix_ShouldDeleteAsSoonAsThatHandleCloses(string path)
+ {
+ 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);
+ using SafeFileHandle second = FileSystem.File.OpenHandle(path,
+ FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite | FileShare.Delete);
+
+ first.Dispose();
+
+ 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,
+ "moving a file that a handle holds open needs `ignoreFileShare`, which is inert on Windows: see #1086");
+
+ 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)).IsTrue()
+ .Because("the name that was opened is unlinked, not the file");
+ }
+
+ [Test]
+ [AutoArguments]
+ 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");
+
+ 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)).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
new file mode 100644
index 000000000..94ca79e97
--- /dev/null
+++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/LengthTests.cs
@@ -0,0 +1,152 @@
+#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_RANDOMACCESS_FLUSHTODISK
+ [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_OnWindows_ShouldThrowUnauthorizedAccess(
+ string path)
+ {
+ Skip.IfNot(Test.RunsOnWindows);
+
+ 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();
+ }
+
+ [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
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..90067e811
--- /dev/null
+++ b/Tests/Testably.Abstractions.Tests/FileSystem/RandomAccess/WriteTests.cs
@@ -0,0 +1,228 @@
+#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, });
+ }
+
+ [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