Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
96f7afb
feat: add mock support for `OpenHandle` and `IRandomAccess`
Mpdreamz Sep 20, 2026
f6c29f3
fix: address review findings on the random-access mock
Mpdreamz Sep 20, 2026
e6ad1cc
fix: hold handles from elsewhere to the same rules
Mpdreamz Sep 20, 2026
27b2626
fix: gate the mock's `SetLength` on the same flag as the interface
Mpdreamz Sep 20, 2026
40d6e6f
chore: consume the released interface and restore the build scope
Mpdreamz Sep 20, 2026
81eb0c6
chore: keep only the comments that explain a non-obvious why
Mpdreamz Sep 20, 2026
60a42f7
fix: match the real file system's `DeleteOnClose` and resize behaviour
Mpdreamz Sep 20, 2026
9360bd7
fix: `RandomAccess.Write` honours the offset on an append handle
Mpdreamz Sep 21, 2026
65e1062
docs: describe `RandomAccess` and handles from the abstraction
Mpdreamz Sep 21, 2026
58677a5
test: cover the `SafeFileHandle` surface and drop the stale exclusions
Mpdreamz Sep 20, 2026
a94fae0
fix: exercise the asynchronous handle overload and mend the docs example
Mpdreamz Sep 20, 2026
d140451
chore: leave the test project files as they are on main
Sep 25, 2026
44fad35
refactor: share the open checks between `FileStreamMock` and `OpenHan…
Sep 25, 2026
ff6e2d9
fix: address the review of the handle registry and `RandomAccessMock`
Sep 25, 2026
d77a29c
test: enforce statistics coverage for `IRandomAccess`
Sep 25, 2026
cbcdf10
docs: describe when a closed handle takes effect
Sep 25, 2026
8bf10ba
Merge #1082 review fixes into the coverage branch
Sep 25, 2026
3b4754d
fix: record `RandomAccess` span buffers on .NET 6 and 8
Sep 25, 2026
6e41ffa
Merge the .NET 6/8 build fix from #1082
Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@
<PackageVersion Include="AutoFixture" Version="4.18.1" />
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Testably.Abstractions.Interface" Version="[10.3.0,10.4.0)"/>
<PackageVersion Include="Testably.Abstractions" Version="[10.3.0,10.4.0)"/>
<PackageVersion Include="Testably.Abstractions.Interface" Version="[10.4.0-pre.1,10.5.0)"/>
<PackageVersion Include="Testably.Abstractions" Version="[10.4.0-pre.1,10.5.0)"/>
<PackageVersion Include="Testably.Abstractions.Testing" Version="6.6.0"/>
</ItemGroup>
<ItemGroup>
Expand Down
69 changes: 69 additions & 0 deletions Docs/pages/docs/file-system/random-access.mdx
Original file line number Diff line number Diff line change
@@ -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<Memory<byte>>`, 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.
39 changes: 33 additions & 6 deletions Docs/pages/docs/file-system/safe-file-handles.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +35 to +39

## A complete reference implementation

Expand Down Expand Up @@ -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"));
Expand All @@ -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.
1 change: 1 addition & 0 deletions Docs/pages/docs/file-system/statistics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ await Expect.That(stats.File.Methods).Contains(m => m.Name == nameof(IFile.Write
| `FileSystemWatcher` | `IPathStatistics<IFileSystemWatcherFactory, IFileSystemWatcher>` |
| `FileVersionInfo` | `IPathStatistics<IFileVersionInfoFactory, IFileVersionInfo>` |
| `Path` | `IStatistics<IPath>` |
| `RandomAccess` | `IStatistics<IRandomAccess>` |

`IStatistics<T>` 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<TFactory, TItem>` adds an indexer keyed by the path that the factory produced - `stats.FileInfo["a.txt"]` returns just the calls made on that specific `IFileInfo`.

Expand Down
2 changes: 1 addition & 1 deletion Pipeline/Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ partial class Build : FalloutBuild
/// <para />
/// Afterward, you can update the package reference in `Directory.Packages.props` and reset this flag.
/// </summary>
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;
Expand Down
35 changes: 20 additions & 15 deletions Source/Testably.Abstractions.Testing/FileSystem/FileMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -827,6 +827,24 @@ public FileSystemStream Open(string path, FileStreamOptions options)
}
#endif

#if FEATURE_FILESYSTEM_RANDOMACCESS
/// <inheritdoc cref="IFile.OpenHandle(string, FileMode, FileAccess, FileShare, FileOptions, long)" />
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

/// <inheritdoc cref="IFile.OpenRead(string)" />
public FileSystemStream OpenRead(string path)
{
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,17 +144,13 @@ private FileStreamMock New(string path,
#endif

/// <inheritdoc cref="IFileStreamFactory.New(SafeFileHandle, FileAccess)" />
#if NET6_0_OR_GREATER
[ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")]
#endif
public FileSystemStream New(SafeFileHandle handle, FileAccess access)
{
using IDisposable registration = _fileSystem.StatisticsRegistration
.FileStream.RegisterMethod(nameof(New),
handle, access);

SafeFileHandleMock safeFileHandleMock = _fileSystem
.SafeFileHandleStrategy.MapSafeFileHandle(handle);
SafeFileHandleMock safeFileHandleMock = MapSafeFileHandle(handle);
return New(
safeFileHandleMock.Path,
safeFileHandleMock.Mode,
Expand All @@ -163,17 +159,13 @@ public FileSystemStream New(SafeFileHandle handle, FileAccess access)
}

/// <inheritdoc cref="IFileStreamFactory.New(SafeFileHandle, FileAccess, int)" />
#if NET6_0_OR_GREATER
[ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")]
#endif
public FileSystemStream New(SafeFileHandle handle, FileAccess access, int bufferSize)
{
using IDisposable registration = _fileSystem.StatisticsRegistration
.FileStream.RegisterMethod(nameof(New),
handle, access, bufferSize);

SafeFileHandleMock safeFileHandleMock = _fileSystem
.SafeFileHandleStrategy.MapSafeFileHandle(handle);
SafeFileHandleMock safeFileHandleMock = MapSafeFileHandle(handle);
return New(
safeFileHandleMock.Path,
safeFileHandleMock.Mode,
Expand All @@ -183,18 +175,14 @@ public FileSystemStream New(SafeFileHandle handle, FileAccess access, int buffer
}

/// <inheritdoc cref="IFileStreamFactory.New(SafeFileHandle, FileAccess, int, bool)" />
#if NET6_0_OR_GREATER
[ExcludeFromCodeCoverage(Justification = "SafeFileHandle cannot be unit tested.")]
#endif
public FileSystemStream New(SafeFileHandle handle, FileAccess access, int bufferSize,
bool isAsync)
{
using IDisposable registration = _fileSystem.StatisticsRegistration
.FileStream.RegisterMethod(nameof(New),
handle, access, bufferSize, isAsync);

SafeFileHandleMock safeFileHandleMock = _fileSystem
.SafeFileHandleStrategy.MapSafeFileHandle(handle);
SafeFileHandleMock safeFileHandleMock = MapSafeFileHandle(handle);
return New(
safeFileHandleMock.Path,
safeFileHandleMock.Mode,
Expand Down Expand Up @@ -234,4 +222,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
}
65 changes: 3 additions & 62 deletions Source/Testably.Abstractions.Testing/FileSystem/FileStreamMock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
{
Expand Down Expand Up @@ -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);
}
}
}
Loading
Loading