https://github.com/nullean/scoped-filesystem
System.IO.Abstractions IFileSystem implementation that limits filesystem access to particular folders.
https://github.com/nullean/scoped-filesystem
Last synced: 3 months ago
JSON representation
System.IO.Abstractions IFileSystem implementation that limits filesystem access to particular folders.
- Host: GitHub
- URL: https://github.com/nullean/scoped-filesystem
- Owner: nullean
- Created: 2026-03-30T11:27:36.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-04-01T06:54:44.000Z (3 months ago)
- Last Synced: 2026-04-06T08:29:48.264Z (3 months ago)
- Language: C#
- Size: 106 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# Nullean.ScopedFileSystem
A `System.IO.Abstractions` `IFileSystem` decorator that restricts file **read and write** operations to one or more configured scope root directories and rejects symbolic links.
## What it does
`ScopedFileSystem` wraps any `IFileSystem` and enforces these rules on every file and directory operation:
| Rule | Detail |
|------|--------|
| **Scope** | The resolved path must be within a configured scope root or an explicitly allowed OS special folder. |
| **No symlinks** | The target and all ancestor directories up to the scope root must not be symbolic links. |
| **No hidden files** | Files whose name starts with `.` are blocked unless the name is in `AllowedHiddenFileNames`. |
| **No hidden directories** | Directories whose name starts with `.` are blocked unless the name is in `AllowedHiddenFolderNames`. This applies to both the target directory and all ancestors up to the scope root. |
| **Exists is safe** | `File.Exists()` and `Directory.Exists()` return `false` for out-of-scope paths instead of throwing. |
Any violation throws `ScopedFileSystemException` (extends `SecurityException`).
## Usage
```csharp
using System.IO.Abstractions;
using Nullean.ScopedFileSystem;
// Convenience: single root, real filesystem
IFileSystem scoped = new ScopedFileSystem("/var/www/docs");
// Convenience: custom inner filesystem (e.g. MockFileSystem in tests)
IFileSystem scoped = new ScopedFileSystem(inner, "/var/www/docs");
// Full control via ScopedFileSystemOptions
IFileSystem scoped = new ScopedFileSystem(inner, new ScopedFileSystemOptions("/var/www/docs")
{
AllowedHiddenFolderNames = [".git"],
AllowedHiddenFileNames = [".gitkeep"],
AllowedSpecialFolders = AllowedSpecialFolder.Temp | AllowedSpecialFolder.ApplicationData,
});
```
```csharp
// OK — within scope
var content = scoped.File.ReadAllText("/var/www/docs/page.md");
// OK — write within scope
scoped.File.WriteAllText("/var/www/docs/output.md", content);
// Throws ScopedFileSystemException — outside scope
scoped.File.ReadAllText("/etc/passwd");
// Throws ScopedFileSystemException — hidden file (.env)
scoped.File.ReadAllText("/var/www/docs/.env");
// Throws ScopedFileSystemException — hidden ancestor directory (.hidden)
scoped.File.ReadAllText("/var/www/docs/.hidden/secret.txt");
// Returns false — no exception
bool exists = scoped.File.Exists("/etc/passwd");
// Throws ScopedFileSystemException — symlink
scoped.File.ReadAllText("/var/www/docs/link-to-secret");
```
## Multiple roots
Pass multiple paths to the constructor. Roots must be fully **disjoint** — no root may be an ancestor of another. Violating this throws `ArgumentException` at construction time.
```csharp
// OK — /docs and /data are siblings
new ScopedFileSystem(inner, "/docs", "/data");
// ArgumentException — /data is an ancestor of /data/sub
new ScopedFileSystem(inner, "/data", "/data/sub");
```
## Hidden files and directories
By default, any file or directory whose name begins with `.` is blocked. Use `ScopedFileSystemOptions` to allow specific names:
```csharp
var scoped = new ScopedFileSystem(new ScopedFileSystemOptions("/project")
{
// Allow traversing .git directories (e.g. to read .git/config)
AllowedHiddenFolderNames = [".git", ".nuget"],
// Allow reading/writing specific hidden files
AllowedHiddenFileNames = [".gitkeep", ".gitignore"],
});
```
Allowlist matching is case-sensitive on Linux and case-insensitive on Windows and macOS, matching the platform filesystem behaviour. Any `IReadOnlyCollection` is accepted — no need to configure a comparer.
## Special folders
Opt in to read and write access outside the scope roots for well-known OS directories using the `AllowedSpecialFolders` flags enum:
| Flag | macOS | Windows | Linux |
|------|-------|---------|-------|
| `Temp` | `/var/folders/…` / `/tmp` | `%TEMP%` | `/tmp` |
| `ApplicationData` | `~/Library/Application Support` | `%APPDATA%` | `~/.config` |
| `LocalApplicationData` | `~/Library/Application Support` | `%LOCALAPPDATA%` | `~/.local/share` |
| `CommonApplicationData` | `/Library/Application Support` | `C:\ProgramData` | `/usr/share` |
| `All` | all four combined | | |
```csharp
var scoped = new ScopedFileSystem(new ScopedFileSystemOptions("/project")
{
AllowedSpecialFolders = AllowedSpecialFolder.Temp | AllowedSpecialFolder.ApplicationData,
});
// OK — temp is allowed
scoped.File.WriteAllText(Path.Combine(Path.GetTempPath(), "cache.bin"), data);
```
Paths are resolved at construction time. Access to special folders bypasses all hidden-file and hidden-directory checks.
## `ScopedFileSystemOptions`
`ScopedFileSystemOptions` is the primary configuration surface. It accepts one or more scope roots either as strings or as `IDirectoryInfo` instances:
```csharp
// String roots
var options = new ScopedFileSystemOptions("/root1", "/root2");
// IDirectoryInfo roots
var options = new ScopedFileSystemOptions(fs.DirectoryInfo.New("/root1"));
```
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `ScopeRoots` | `IReadOnlyList` | _(required on constructor)_ | Scope root paths |
| `AllowedHiddenFileNames` | `IReadOnlyCollection` | empty | Hidden file names that are allowed |
| `AllowedHiddenFolderNames` | `IReadOnlyCollection` | empty | Hidden directory names that are allowed |
| `AllowedSpecialFolders` | `AllowedSpecialFolder` | `None` | OS special folders to allow |
The inner `IFileSystem` is passed directly to `ScopedFileSystem` rather than through options; overloads without an explicit `inner` default to `new FileSystem()`.
## Path validation
All paths are resolved via `IPath.GetFullPath` before any check. This means traversal sequences like `..` are collapsed first, so `"/docs/../etc/passwd"` is correctly identified as `"/etc/passwd"` and rejected. Scope membership is verified by walking up the directory tree rather than string prefix matching, which prevents sibling-prefix attacks (e.g. `/docs-extra` is not inside `/docs`).
## Platform behaviour
- **Case sensitivity**: Path comparison is case-sensitive on Linux, case-insensitive on Windows and macOS.
- **Separator normalisation**: Both `/` and `\` separators are handled correctly on all platforms.
## Projects
| Project | Description |
|---------|-------------|
| `src/Nullean.ScopedFileSystem` | Library |
| `tests/Nullean.ScopedFileSystem.Tests` | xUnit test suite using `MockFileSystem` |
## Building
```bash
dotnet build
dotnet test
```