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
2 changes: 1 addition & 1 deletion Docs/Nuget/Interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ dotnet add package Testably.Abstractions.Interface
> Most users install [`Testably.Abstractions`](https://www.nuget.org/packages/Testably.Abstractions) (production) and [`Testably.Abstractions.Testing`](https://www.nuget.org/packages/Testably.Abstractions.Testing) (tests) instead - both pull these interfaces in transitively.

- `IFileSystem` mirrors `System.IO` (`File`, `Directory`, `FileInfo`, `DirectoryInfo`, `FileStream`, `Path`, `DriveInfo`, `FileSystemWatcher`, `FileVersionInfo`). Constructors are exposed as factory methods (e.g. `fileSystem.FileInfo.New(path)`). Lives in the `System.IO.Abstractions` namespace, so it is source-compatible with [`TestableIO.System.IO.Abstractions`](https://github.com/TestableIO/System.IO.Abstractions).
- `ITimeSystem` covers `DateTime`, `Stopwatch`, `Task.Delay`, `Thread.Sleep`, `Timer` and (on supported targets) `PeriodicTimer`.
- `ITimeSystem` covers `DateTime`, `DateTimeOffset`, `Stopwatch`, `Task.Delay`, `Thread.Sleep`, `Timer`, `TimeZoneInfo` and (on .NET 8 or later) `PeriodicTimer`.
- `IRandomSystem` covers `Random` (with a thread-safe `Shared` instance on all targets) and `Guid`.
2 changes: 2 additions & 0 deletions Docs/Nuget/MemoryMappedFiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Memory-mapped file extensions for [`Testably.Abstractions`](https://www.nuget.or
dotnet add package Testably.Abstractions.MemoryMappedFiles
```

**Full documentation: [docs.testably.org/Abstractions/companion-libraries/memory-mapped-files](https://docs.testably.org/Abstractions/companion-libraries/memory-mapped-files)**

```csharp
IFileSystem fileSystem; // injected

Expand Down
2 changes: 1 addition & 1 deletion Docs/Nuget/Testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The test suite runs every assertion against both the real and the mocked file sy

- **MockFileSystem** - fluent `Initialize()` API, multiple drives with size limits, `FileSystemWatcher`, `SafeFileHandle`, file-version metadata and unix file modes.
- **Cross-platform simulation** - run a Linux, macOS or Windows file system regardless of the host via `new MockFileSystem(o => o.SimulatingOperatingSystem(SimulationMode.Linux))`.
- **Intercept and Notify** - inject exceptions before a file-system or time-system operation completes, or react to them after the fact (including replay of events that fired before the subscription).
- **Intercept and Notify** - inject exceptions before a file-system operation completes, or react to it after the fact (including replay of events that fired before the subscription). The time system offers the same notifications for sleeps, delays and timer ticks.
- **MockTimeSystem** - control `DateTime.Now`, advance time manually or via auto-advance, mock `Timer`/`PeriodicTimer` with persistent execution counters.
- **MockRandomSystem** - seed `Random` and `Guid.NewGuid()` for reproducible tests.
- **Statistics** - inspect how the system-under-test used each abstraction (`fileSystem.Statistics`).
3 changes: 2 additions & 1 deletion Docs/pages/docs/companion-libraries/access-control.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
sidebar_position: 2
title: Access control
description: GetAccessControl and SetAccessControl on IFileSystem, so ACL code runs against the real and the mocked file system.
---

# Testably.Abstractions.AccessControl
Expand Down Expand Up @@ -39,7 +40,7 @@ FileSecurity fileAcl = fileSystem.File.GetAccessControl("secret.txt");

On `RealFileSystem` the calls forward to `System.IO.FileSystemAclExtensions`. On `MockFileSystem` the ACL object is stored as metadata via [`IFileSystemExtensibility`](../file-system/extensibility) and round-tripped - set it, read it back, and it will be the same object.

To simulate *enforcement* (denying a read or write based on the ACL), combine the package with [`MockFileSystem.WithAccessControlStrategy(...)`](../file-system/access-control).
The mock does not enforce the stored ACL. To simulate denied access, combine the package with [`MockFileSystem.WithAccessControlStrategy(...)`](../file-system/access-control).

:::warning[Windows-only]
All ACL APIs are marked `[SupportedOSPlatform("windows")]`. Calling them on Linux/macOS throws `PlatformNotSupportedException`.
Expand Down
18 changes: 5 additions & 13 deletions Docs/pages/docs/companion-libraries/compression.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
sidebar_position: 1
title: Compression
description: ZipFile and ZipArchive on IFileSystem, so zip code runs against the real and the mocked file system.
---

# Testably.Abstractions.Compression
Expand All @@ -15,7 +16,7 @@ dotnet add package Testably.Abstractions.Compression

## Why this package exists

`System.IO.Compression.ZipFile` operates on real `string` paths and `System.IO.Compression.ZipArchive` operates on `Stream`s - neither knows about `IFileSystem`. Without the companion package, bridging the two means writing your own glue:
`System.IO.Compression.ZipFile` operates on real `string` paths and `System.IO.Compression.ZipArchive` operates on `Stream`s - neither knows about `IFileSystem`. The package bridges the two:

```csharp
// With Testably.Abstractions.Compression - one line:
Expand Down Expand Up @@ -58,18 +59,9 @@ Extraction looks similar: open a `ZipArchive` in `Read` mode, loop the entries,

The package replaces this glue with a one-line call. Beyond brevity, it also gives you stream/path overloads on both sides, async variants on .NET 10+, and an `IZipArchive` / `IZipArchiveEntry` wrapper so the abstraction extends through the archive itself rather than stopping at the zip boundary.

### Production uses the real BCL implementation

The wrapper isn't just a uniform façade - at runtime it forwards every call to the matching `System.IO.Compression.ZipFile` / `ZipArchive` method when `IFileSystem` is a `RealFileSystem`, and to an in-memory equivalent only when it's a `MockFileSystem`:

```csharp
// Inside ZipFileWrapper.CreateFromDirectory:
Execute.WhenRealFileSystem(FileSystem,
() => ZipFile.CreateFromDirectory(sourceDirectoryName, destination), // real → BCL
() => ZipUtilities.CreateFromDirectory(FileSystem, sourceDirectoryName, destination)); // mock → in-memory
```

That means production code keeps every native optimisation the BCL ships - kernel-level streaming, no extra `MemoryStream` allocation, the latest deflate tuning - while tests run against a deterministic in-memory implementation. You don't trade performance for testability.
:::info[Production uses the real implementation]
On `RealFileSystem`, every call forwards to the matching `System.IO.Compression` method, so production code keeps the performance of the base class library (BCL). Only `MockFileSystem` uses an in-memory implementation.
:::

## ZipFile

Expand Down
7 changes: 4 additions & 3 deletions Docs/pages/docs/companion-libraries/index.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
title: Companion libraries
description: Optional packages that add compression, access control and memory-mapped files to IFileSystem.
---

# Companion libraries
Expand All @@ -8,8 +9,8 @@ title: Companion libraries

| Package | Adds to `IFileSystem` |
|------------------------------------------|--------------------------------------------------------------------|
| `Testably.Abstractions.Compression` | `ZipFile` and `ZipArchive` extension properties |
| `Testably.Abstractions.AccessControl` | `GetAccessControl` / `SetAccessControl` on files and directories |
| `Testably.Abstractions.MemoryMappedFiles`| `MemoryMappedFile` extension property |
| [`Testably.Abstractions.Compression`](./compression) | `ZipFile` and `ZipArchive` extension properties |
| [`Testably.Abstractions.AccessControl`](./access-control) | `GetAccessControl` / `SetAccessControl` on files and directories |
| [`Testably.Abstractions.MemoryMappedFiles`](./memory-mapped-files)| `MemoryMappedFile` extension property |

All packages target the `IFileSystem` interface, so they work transparently against either `RealFileSystem` or `MockFileSystem`.
62 changes: 28 additions & 34 deletions Docs/pages/docs/companion-libraries/memory-mapped-files.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
sidebar_position: 3
title: Memory-mapped files
description: MemoryMappedFile on IFileSystem, so memory-mapped file code can be tested against the MockFileSystem.
---

# Testably.Abstractions.MemoryMappedFiles
Expand Down Expand Up @@ -30,22 +31,9 @@ int value = accessor.ReadInt32(0);

The same code runs unchanged against `RealFileSystem` and `MockFileSystem`.

### Production uses the real base class library (BCL) implementation

The abstraction isn't just a uniform façade. When `IFileSystem` is a `RealFileSystem`, every call forwards to the underlying BCL `MemoryMappedFile`, so production keeps the native memory-mapping semantics unchanged. Only when `IFileSystem` is a `MockFileSystem` are the views built directly over the in-memory file bytes:

```csharp
// Inside MemoryMappedFileFactory.CreateFromFile:
if (extensibility.TryGetWrappedInstance(out FileStream? realStream))
{
// real → BCL MemoryMappedFile over the real FileStream
return new MemoryMappedFileWrapper(FileSystem,
MemoryMappedFile.CreateFromFile(realStream, ...), backingStream);
}

// mock → view over the in-memory file bytes
return new MemoryMappedFileMock(FileSystem, stream, capacity, access, ownsStream);
```
:::info[Production uses the real implementation]
On `RealFileSystem`, every call forwards to the base class library (BCL) `MemoryMappedFile`, so production keeps the native memory-mapping semantics. Only on `MockFileSystem` are the views built over the in-memory file bytes.
:::

## Creating a memory-mapped file

Expand Down Expand Up @@ -84,32 +72,38 @@ stream.Write([1, 2, 3, 4], 0, 4);

`IMemoryMappedViewAccessor` mirrors `MemoryMappedViewAccessor`: the primitive `Read*`/`Write` overloads, the generic `Read<T>`/`Write<T>` for structs, `ReadArray<T>`/`WriteArray<T>`, plus `Capacity`, `CanRead`, `CanWrite`, `PointerOffset` and `Flush`.

`MemoryMappedFileSystemViewStream` derives from `UnmanagedMemoryStream` - the same base class as the real `MemoryMappedViewStream` - and re-declares the `Capacity` member on top of the stream members (the base is not backed by unmanaged memory), plus the `PointerOffset` member specific to `MemoryMappedViewStream`.
`MemoryMappedFileSystemViewStream` derives from `UnmanagedMemoryStream`, like the real `MemoryMappedViewStream`, and adds its `Capacity` and `PointerOffset` members.

## Behaviour on the mock

On `MockFileSystem` the reads and writes, the capacity handling and the thrown exceptions mirror the real `MemoryMappedViewAccessor` / `MemoryMappedViewStream`, including:

- **Default view access is `ReadWrite`** - creating a writable view over a read-only mapping throws `UnauthorizedAccessException`, exactly as the BCL does.
- **The file and its views have independent lifetimes** - a view stays usable after the `IMemoryMappedFile` is disposed.
- **`CopyOnWrite` views privatize pages on write** - their writes are neither persisted nor visible to other views, while pages they have not written keep reflecting changes made through other views.

## Not supported on the mock
### Not supported

Some parts of the BCL surface operate on operating-system shared memory rather than a file, so they have no meaningful in-memory equivalent. They forward normally on `RealFileSystem`, but throw `NotSupportedException` on `MockFileSystem`:
These parts of the BCL operate on operating-system shared memory rather than a file. They forward normally on `RealFileSystem`, but throw `NotSupportedException` on `MockFileSystem`:

- `CreateNew`
- `CreateOrOpen` *(Windows-only)*
- `OpenExisting` *(Windows-only)*
- A non-null `mapName` in `CreateFromFile` (named mappings are operating-system shared-memory objects)

The `SafeMemoryMappedFileHandle` / `SafeMemoryMappedViewHandle` handle and pointer APIs, as well as the `SafeFileHandle`-based `CreateFromFile` overload, are not exposed at all. Since no unmanaged memory backs a `MemoryMappedFileSystemViewStream`, the members that only exist on its `UnmanagedMemoryStream` base throw `ObjectDisposedException`: the unsafe `PositionPointer`, and `Capacity` when accessed through a reference typed as `UnmanagedMemoryStream` instead of `MemoryMappedFileSystemViewStream`.
- A non-null `mapName` in `CreateFromFile` (named mappings are shared-memory objects)

## Behaviour on the mock
The `SafeMemoryMappedFileHandle` / `SafeMemoryMappedViewHandle` handle and pointer APIs, and the `SafeFileHandle`-based `CreateFromFile` overload, are not exposed at all. The unsafe `PositionPointer` of a view stream throws `ObjectDisposedException`, because no unmanaged memory backs it.

On `MockFileSystem` the reads and writes, the capacity handling and the thrown exceptions mirror the real `MemoryMappedViewAccessor` / `MemoryMappedViewStream`, including:
### Differences from the real file system

- **Default view access is `ReadWrite`** - creating a writable view over a read-only mapping throws `UnauthorizedAccessException`, exactly as the BCL does.
- **The file and its views have independent lifetimes** - a view stays usable after the `IMemoryMappedFile` is disposed.
- **`CopyOnWrite` views privatize pages on write** - writes are neither persisted to the underlying file nor visible to other views, while pages the view has not written keep reflecting later changes made through other views, matching the lazy page privatization (4096-byte granularity) of real copy-on-write views.
<details>
<summary>Details tied to operating-system memory mapping that the mock simplifies</summary>

Some details tied to operating-system memory mapping are intentionally simplified:
- **View size:** a view created without an explicit size has a `Capacity` of exactly the remaining bytes (the real file system rounds up to the page size), and `PointerOffset` is always `0`.
- **Visibility of writes:** writes are immediately visible to all views of the same mapping, but reach the underlying file (and other streams of it) only through the backing stream, at the latest when a view is flushed or disposed. The real operating system makes them visible to other readers immediately.
- **Disposed backing stream:** when the stream passed to `CreateFromFile` with `leaveOpen: true` is disposed while views are open, operations on those views throw `ObjectDisposedException`. Real views keep working.
- **Truncation:** truncating the backing stream while views are open succeeds, and reads of the truncated range return zeros. The real operating system rejects the truncation.
- **Size:** the mock keeps the whole file in memory, so a memory-mapped file is limited to 2 GB. Growing it beyond that throws `NotSupportedException`.
- **Exceptions:** exception types and messages follow the Windows behaviour of the BCL, also on Linux and macOS and for every simulated operating system.
- **Struct size on .NET Framework:** `Read<T>`/`Write<T>`/`ReadArray<T>`/`WriteArray<T>` size structs by their managed layout, like .NET (Core). The .NET Framework uses the marshalled size, so the two disagree for structs such as one containing a `bool`.

- A view created without an explicit size has a `Capacity` of exactly the remaining bytes (the real file system rounds up to the system page size), and `PointerOffset` is always `0`.
- The views of the mock read and write through the backing stream instead of mapped memory. Writes are immediately visible to all views of the same mapping, but reach the underlying file (and other open streams of it) only when a view is flushed or disposed, or earlier when the backing stream persists its pending writes on a reposition - the real operating system makes them visible to other readers immediately. For the same reason, when the stream passed to `CreateFromFile` with `leaveOpen: true` is disposed while views are still open, operations on those views throw `ObjectDisposedException` (disposing them stays safe), whereas real views keep operating on the mapped pages without the file handle.
- The mock does not track the open mappings of a file, so truncating the backing stream (for example via `SetLength` on a stream passed with `leaveOpen: true`) while views are open succeeds, and reads of the truncated range afterwards return zeros. The real operating system rejects such a truncation (on Windows with an `IOException` about an open user-mapped section).
- The mocked file system stores the complete file content in memory, which limits the size of a memory-mapped file to 2 GB. Growing a file beyond that throws a `NotSupportedException`, whereas the real file system creates sparse multi-gigabyte mappings.
- Exception types and messages of the mock follow the Windows behaviour of the BCL, regardless of the operating system the tests run on and of the simulated operating system of the `MockFileSystem`. On Linux and macOS the real file system can throw different exception types for the same invalid call (for example when creating a view with execute access or growing a read-only mapping).
- Structs in `Read<T>`/`Write<T>`/`ReadArray<T>`/`WriteArray<T>` are sized by their managed layout (`Unsafe.SizeOf<T>`), matching the BCL on .NET (Core). The real `MemoryMappedViewAccessor` of the .NET Framework uses the marshalled size instead, so on .NET Framework the mock and the real accessor disagree for structs whose marshalled size differs from the managed one (for example a struct containing a `bool`: 1 byte managed, 4 bytes marshalled).
</details>
16 changes: 10 additions & 6 deletions Docs/pages/docs/file-system/access-control.mdx
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
---
sidebar_position: 7
title: Access control (ACL)
title: Simulating access denial (ACL)
sidebar_label: Access denial (ACL)
description: Deny access to selected paths in the MockFileSystem to test how code handles missing Windows permissions.
---

# Access control (ACL)
# Simulating access denial (ACL)

To simulate Windows ACL-based access restrictions, `MockFileSystem` accepts an `IAccessControlStrategy` via `WithAccessControlStrategy(...)`.
To simulate Windows access control list (ACL) restrictions, register an `IAccessControlStrategy` with `WithAccessControlStrategy(...)`.

The default implementation takes a callback `(path, extensibility) => bool` - return `false` to deny access to that path. When access is denied, the next read/write operation throws `IOException` with the message `"Access to the path '{fullPath}' is denied."`, matching what the real Windows file system does.
The built-in `DefaultAccessControlStrategy` takes a callback `(fullPath, extensibility) => bool` - return `false` to deny access to that path. Operations on a denied path then throw `UnauthorizedAccessException` with the message `"Access to the path '{fullPath}' is denied."`, like the real Windows file system:

```csharp
MockFileSystem fileSystem = new();
// highlight-start
fileSystem.WithAccessControlStrategy(
new DefaultAccessControlStrategy((path, _) => !path.EndsWith("secret.txt")));
// highlight-end

fileSystem.File.WriteAllText("secret.txt", "x"); // throws IOException
fileSystem.File.WriteAllText("public.txt", "x"); // ok
fileSystem.File.WriteAllText("secret.txt", "x"); // throws UnauthorizedAccessException
```

:::warning[Windows-only]
Expand All @@ -24,4 +28,4 @@ fileSystem.File.WriteAllText("public.txt", "x"); // ok

## Reading and writing ACL objects

For full `FileSecurity` / `DirectorySecurity` support (the actual `GetAccessControl` / `SetAccessControl` APIs), install the [`Testably.Abstractions.AccessControl`](../companion-libraries/access-control) companion package. It adds extension methods on `IFile`, `IFileInfo`, `IDirectory`, `IDirectoryInfo` and `FileSystemStream` that store ACL objects on the mock and round-trip them on the real file system.
This page is about *denying* access. To read and write `FileSecurity` / `DirectorySecurity` objects (the `GetAccessControl` / `SetAccessControl` APIs), install the [`Testably.Abstractions.AccessControl`](../companion-libraries/access-control) companion package.
Loading
Loading