Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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.

## 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 @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
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