diff --git a/CLAUDE.md b/CLAUDE.md index d34982c..c919a72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,9 +5,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**ZipDrive** is a clean-architecture rewrite of the ZipDrive virtual file system. It mounts archive files (ZIP, RAR, and extensible to other formats) as accessible Windows drives using DokanNet. The project has a **format-agnostic caching layer, streaming ZIP reader, and RAR support via SharpCompress**. +**ZipDrive** is a clean-architecture rewrite of the ZipDrive virtual file system. It mounts archive files (ZIP, RAR, and extensible to other formats) as accessible Windows drives using WinFsp. The project has a **format-agnostic caching layer, streaming ZIP reader, and RAR support via SharpCompress**. -**Current Status**: Multi-format archive support with format-agnostic provider architecture (`IArchiveStructureBuilder`, `IArchiveEntryExtractor`, `IPrefetchStrategy`, `IFormatRegistry`). ZIP provider with chunked incremental extraction, streaming Central Directory reader, sibling prefetch with coalescing batch reader. RAR provider via SharpCompress with binary signature detection (RAR4/RAR5 magic bytes + solid flag in ~64 bytes). Solid RAR archives shown as `name.rar (NOT SUPPORTED)` with warning file, or hidden via `Mount:HideUnsupportedArchives`. File content cache with strategy-owned materialization, OpenTelemetry observability, DokanNet adapter, background cache maintenance, automatic charset detection for non-UTF8 filenames, drag-and-drop folder launch. Per-archive dynamic reload with multi-format FileSystemWatcher. The caching layer (`Infrastructure.Caching`) has zero format-specific dependencies — all format operations accessed through Domain interfaces. Concurrency model formally verified with TLA+ (`specs/formal/`). 450+ tests passing, 12-hour endurance soak validated. +**Current Status**: Multi-format archive support with format-agnostic provider architecture (`IArchiveStructureBuilder`, `IArchiveEntryExtractor`, `IPrefetchStrategy`, `IFormatRegistry`). ZIP provider with chunked incremental extraction, streaming Central Directory reader, sibling prefetch with coalescing batch reader. RAR provider via SharpCompress with binary signature detection (RAR4/RAR5 magic bytes + solid flag in ~64 bytes). Solid RAR archives shown as `name.rar (NOT SUPPORTED)` with warning file, or hidden via `Mount:HideUnsupportedArchives`. File content cache with strategy-owned materialization, OpenTelemetry observability, WinFsp adapter, background cache maintenance, automatic charset detection for non-UTF8 filenames, drag-and-drop folder launch. Per-archive dynamic reload with multi-format FileSystemWatcher. The caching layer (`Infrastructure.Caching`) has zero format-specific dependencies — all format operations accessed through Domain interfaces. Concurrency model formally verified with TLA+ (`specs/formal/`). 450+ tests passing, 12-hour endurance soak validated. ## Development Workflow Requirements @@ -35,7 +35,7 @@ Code Change → Build → Write Tests → Run Tests → Pass → Done ## Cross-Platform Considerations -ZipDrive's CLI and DokanNet adapter are **Windows-only**, but the underlying infrastructure libraries (`ZipDrive.Infrastructure.Caching`, `ZipDrive.Infrastructure.Archives.Zip`, `ZipDrive.Domain`, `ZipDrive.Application`) are designed to be **cross-platform**. Key platform-specific handling: +ZipDrive's CLI and WinFsp adapter are **Windows-only**, but the underlying infrastructure libraries (`ZipDrive.Infrastructure.Caching`, `ZipDrive.Infrastructure.Archives.Zip`, `ZipDrive.Domain`, `ZipDrive.Application`) are designed to be **cross-platform**. Key platform-specific handling: - **Sparse file creation** (`ChunkedDiskStorageStrategy.SetSparseAttribute`): On Windows/NTFS, must explicitly call `FSCTL_SET_SPARSE` via `DeviceIoControl` P/Invoke before `SetLength`, otherwise the OS pre-allocates the full file size. On Linux (ext4/btrfs/xfs), `SetLength` creates sparse files by default — no ioctl needed. The method uses `OperatingSystem.IsWindows()` to branch. - **File sharing** (`FileShare.Read` / `FileShare.ReadWrite`): Used by `ChunkedFileEntry` (writer) and `ChunkedStream` (readers) for concurrent access to the backing sparse file. Works on both Windows and Linux. @@ -43,8 +43,8 @@ ZipDrive's CLI and DokanNet adapter are **Windows-only**, but the underlying inf ## Prerequisites -- **Windows x64 only** - Uses DokanNet which is Windows-specific -- **Dokany v2.3.1.1000** must be installed from https://github.com/dokan-dev/dokany/releases/tag/v2.3.1.1000 +- **Windows x64 only** - Uses WinFsp which is Windows-specific +- **WinFsp v2.0+** must be installed from https://winfsp.dev/rel/ - **.NET 10.0 SDK** (Note: Currently targets .NET 10 preview - may need adjustment to .NET 8 LTS for production) ## Build and Run Commands @@ -122,7 +122,7 @@ ZipDrive follows **Clean Architecture** (Onion Architecture) with strict depende │ Complete Read Flow │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ -│ 1. DokanNet: ReadFile("R:\archive.zip\folder\file.txt", offset=5000) │ +│ 1. WinFsp: ReadFile("R:\archive.zip\folder\file.txt", offset=5000) │ │ ↓ │ │ 2. Archive Prefix Tree: Resolve path │ │ ├── archiveKey = "archive.zip" │ @@ -140,7 +140,7 @@ ZipDrive follows **Clean Architecture** (Onion Architecture) with strict depende │ ├── HIT: Return cached random-access stream │ │ └── MISS: Stream extract → Materialize → Cache → Return │ │ ↓ │ -│ 6. Stream: Seek(5000), Read(4096) → Return to DokanNet │ +│ 6. Stream: Seek(5000), Read(4096) → Return to WinFsp │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` @@ -361,25 +361,25 @@ RAR archive support via SharpCompress (MIT, pure managed C#). #### **FileSystem (`src/ZipDrive.Infrastructure.FileSystem`)** -DokanNet integration for Windows file system mounting. +WinFsp integration for Windows file system mounting. **Key Components**: -- `DokanFileSystemAdapter`: Implements `IDokanOperations2`, translates Dokan calls to `IVirtualFileSystem` -- `DokanHostedService`: `IHostedService` that manages mount/unmount lifecycle -- `DokanTelemetry`: Static `Meter("ZipDrive.Dokan")` with read latency histogram +- `WinFspFileSystemAdapter`: Extends `Fsp.FileSystemBase`, translates WinFsp calls to `IVirtualFileSystem` +- `WinFspHostedService`: `IHostedService` that manages mount/unmount lifecycle +- `FileSystemTelemetry`: Static `Meter("ZipDrive.FileSystem")` with read latency histogram - `ShellMetadataFilter`: Zero-allocation static helper that identifies Windows shell metadata paths (`desktop.ini`, `thumbs.db`, `$RECYCLE.BIN`, etc.) using `ReadOnlySpan` matching - `MountSettings` (in `Domain.Configuration`): Configuration POCO with all mount options including `ShortCircuitShellMetadata`, `FallbackEncoding`, `EncodingConfidenceThreshold`, `DynamicReloadQuietPeriodSeconds`, and `UseFolderNameAsVolumeLabel` - **ArchiveChangeConsolidator**: Queues `FileSystemWatcher` events, consolidates into net deltas after configurable quiet period (`DynamicReloadQuietPeriodSeconds`). State machine: Created+Deleted=Noop, Deleted+Created=Modified. Atomic flush via `Interlocked.Exchange`. `DisposeAsync` awaits in-flight flush. - **IArchiveManager**: Interface separating archive lifecycle (`AddArchiveAsync`, `RemoveArchiveAsync`, `GetRegisteredArchives`) from file system operations (ISP). Implemented by `ZipVirtualFileSystem`. -- **DokanFileSystemAdapter.Guarded*Async**: Five public async methods for test consumption without Dokany runtime (`GuardedReadFileAsync`, `GuardedListDirectoryAsync`, `GuardedGetFileInfoAsync`, `GuardedFileExistsAsync`, `GuardedDirectoryExistsAsync`). +- **WinFspFileSystemAdapter.Guarded*Async**: Five public async methods for test consumption without WinFsp runtime (`GuardedReadFileAsync`, `GuardedListDirectoryAsync`, `GuardedGetFileInfoAsync`, `GuardedFileExistsAsync`, `GuardedDirectoryExistsAsync`). -**ReadFile Buffer Pooling**: `DokanFileSystemAdapter.ReadFile()` uses `ArrayPool.Shared.Rent()` to avoid per-read `byte[]` allocations. The rented array may be larger than the Dokan native buffer, so `bytesRead` is capped to `buffer.Span.Length` and only valid bytes are copied via `AsSpan(0, bytesRead).CopyTo(buffer.Span)`. The array is returned in a `finally` block. **Do NOT return the rented array via `buffer.ReturnArray(rentedArray, copyBack: true)`** — when `copyBack` is `true` the API copies the entire rented array back to the native buffer (no byte-count parameter), which leaks stale `ArrayPool` data beyond `bytesRead`. +**ReadFile Buffer Pooling**: `WinFspFileSystemAdapter.ReadFile()` uses `ArrayPool.Shared.Rent()` to avoid per-read `byte[]` allocations. The rented array may be larger than the WinFsp native buffer, so `bytesRead` is capped to `buffer.Span.Length` and only valid bytes are copied via `AsSpan(0, bytesRead).CopyTo(buffer.Span)`. The array is returned in a `finally` block. **Do NOT return the rented array via `buffer.ReturnArray(rentedArray, copyBack: true)`** — when `copyBack` is `true` the API copies the entire rented array back to the native buffer (no byte-count parameter), which leaks stale `ArrayPool` data beyond `bytesRead`. **Shell Metadata Short-Circuit**: Windows Explorer probes every folder for metadata files like `desktop.ini`, `thumbs.db`, and `autorun.inf`. Without filtering, these probes trigger unnecessary ZIP Central Directory parsing. The `ShellMetadataFilter` intercepts these in `CreateFile` before any string allocation occurs, returning `FileNotFound` immediately. Controlled via `Mount:ShortCircuitShellMetadata` in `appsettings.jsonc`. -**Unmanaged Memory (~394MB)**: Profiling shows ~394MB unmanaged memory at runtime. This is almost entirely **Dokany driver infrastructure** (Dokany kernel driver and user-mode library `dokan2.dll` communication buffers), not ZipDrive code. The managed heap is typically ~1-2MB. This is the normal baseline cost of a FUSE-like file system on Windows and is outside our control. +**Unmanaged Memory (~394MB)**: Profiling shows ~394MB unmanaged memory at runtime. This is almost entirely **WinFsp driver infrastructure** (WinFsp kernel driver and user-mode library `winfsp-x64.dll` communication buffers), not ZipDrive code. The managed heap is typically ~1-2MB. This is the normal baseline cost of a FUSE-like file system on Windows and is outside our control. -**Debug Logging**: All Dokan file system operations log at `Debug` level with the command name and file path, enabling detailed diagnostics when the Serilog minimum level is lowered. +**Debug Logging**: All WinFsp file system operations log at `Debug` level with the command name and file path, enabling detailed diagnostics when the Serilog minimum level is lowered. ### Presentation Layer (`src/ZipDrive.Cli`) @@ -394,7 +394,7 @@ Command-line interface entry point with OpenTelemetry SDK wiring. **Drag-and-Drop Support**: When a folder is dragged onto `ZipDrive.exe`, Windows passes the path as a bare positional arg (`args[0]`). `ArgPreprocessor.RewriteBareArgs()` detects this (first arg not starting with `--`) and prepends `--Mount:ArchiveDirectory=` to the args array. Prepending ensures an explicit `--Mount:ArchiveDirectory` later in the args wins (last-wins semantics). The host builder uses `UseContentRoot(AppContext.BaseDirectory)` so config files are found relative to the exe, not the dragged folder's location. Command-line args are re-added at the end of `ConfigureAppConfiguration` to override `appsettings.jsonc` defaults. -**Validation UX**: `DokanHostedService` validates `ArchiveDirectory` is a non-empty, existing directory. On validation failure, the error is printed to stderr and `Console.ReadKey()` keeps the auto-created console window open so drag-and-drop users can read the message. +**Validation UX**: `WinFspHostedService` validates `ArchiveDirectory` is a non-empty, existing directory. On validation failure, the error is printed to stderr and `Console.ReadKey()` keeps the auto-created console window open so drag-and-drop users can read the message. ## Key Design Patterns @@ -408,7 +408,7 @@ Command-line interface entry point with OpenTelemetry SDK wiring. | **Chunked Extraction** | `ChunkedDiskStorageStrategy`, `ChunkedFileEntry`, `ChunkedStream` | Incremental decompression with per-chunk TCS signaling | | **Async Cleanup** | `ChunkedDiskStorageStrategy` | Non-blocking eviction | | **Dual-Tier Routing** | `FileContentCache` | Size-based memory/disk routing | -| **Static Telemetry** | `CacheTelemetry`, `ZipTelemetry`, `DokanTelemetry` | Zero-DI metrics/tracing | +| **Static Telemetry** | `CacheTelemetry`, `ZipTelemetry`, `FileSystemTelemetry` | Zero-DI metrics/tracing | | **Object Pooling** | Archive sessions (future) | Reuse expensive resources | | **Bytes-First Decoding** | `ZipCentralDirectoryEntry.FileNameBytes` | Defer string decode until encoding is known | | **Arg Rewriting** | `ArgPreprocessor` | Translate bare positional args to named config keys for drag-and-drop | @@ -428,7 +428,7 @@ When working with the caching layer: 6. **Borrow/Return pattern is mandatory** - Always dispose `ICacheHandle` to allow eviction 7. **Entries with RefCount > 0 are protected** - Never evict borrowed entries 8. **ChunkedStream readers must use unbuffered FileStream** - `bufferSize: 1` prevents stale reads from sparse file regions written by the background extractor (the internal FileStream buffer can cache zeros from unwritten regions before the chunk is extracted) -9. **ArchiveTrie uses ReaderWriterLockSlim** - Reads (every Dokan callback) take shared lock; writes (add/remove) take exclusive lock. `ListFolder` materializes results inside the lock. +9. **ArchiveTrie uses ReaderWriterLockSlim** - Reads (every WinFsp callback) take shared lock; writes (add/remove) take exclusive lock. `ListFolder` materializes results inside the lock. 10. **ArchiveNode drain prevents new operations** - `TryEnter()` returns false during drain. Prefetch must hold the guard and use `DrainToken` for cancellation. ## Testing Strategy @@ -643,7 +643,7 @@ Refer to [`IMPLEMENTATION_CHECKLIST.md`](src/Docs/IMPLEMENTATION_CHECKLIST.md) f | Component | Status | Description | |-----------|--------|-------------| | ZipArchiveProvider | ⏳ Pending | `IArchiveProvider` implementation | -| DokanNet Adapter | ✅ Complete | `DokanFileSystemAdapter` + `DokanHostedService` | +| WinFsp Adapter | ✅ Complete | `WinFspFileSystemAdapter` + `WinFspHostedService` | | CLI | ✅ Complete | OTel wiring, FileContentCache DI, config binding, single-file publish | | Multi-archive | ✅ Complete | `ArchiveTrie` + `ArchiveDiscovery` | | Observability | ✅ Complete | OpenTelemetry metrics/tracing, Aspire Dashboard | @@ -652,13 +652,13 @@ Refer to [`IMPLEMENTATION_CHECKLIST.md`](src/Docs/IMPLEMENTATION_CHECKLIST.md) f | Chunked Extraction | ✅ Complete | `ChunkedDiskStorageStrategy` with incremental 10MB chunks, per-chunk TCS signaling, 66 new tests | | Endurance Testing | ✅ Complete | 24-hour soak test with 100 concurrent tasks, full + partial SHA-256 verification, fail-fast diagnostics, latency reporting | | Charset Detection | ✅ Complete | Automatic encoding detection for non-UTF8 ZIP filenames (Shift-JIS, GBK, EUC-KR, etc.) | -| Drag-and-Drop Launch | ✅ Complete | `ArgPreprocessor` rewrites bare args, `DokanHostedService` validates directory + press-any-key UX | +| Drag-and-Drop Launch | ✅ Complete | `ArgPreprocessor` rewrites bare args, `WinFspHostedService` validates directory + press-any-key UX | | Sibling Prefetch | ✅ Complete | `SpanSelector` + coalescing batch reader; fire-and-forget on cold reads and directory listings, per-directory in-flight guard, fill-ratio span selection, 15 new tests | | Dynamic Reload | ✅ Complete | Per-archive add/remove with FileSystemWatcher, ArchiveChangeConsolidator, ArchiveNode drain guard, GenericCache.TryRemove, FileContentCache.RemoveArchive, IArchiveManager, DynamicReloadSuite endurance test | ## Known Limitations / Future Work -- [x] Mount/Unmount implementation (DokanNet integration) - **Implemented** +- [x] Mount/Unmount implementation (WinFsp integration) - **Implemented** - [x] CLI argument parsing and hosted service - **Implemented** - [x] Dual-tier coordinator (automatic memory/disk routing) - **Implemented** - [x] OpenTelemetry observability (metrics, tracing, Aspire Dashboard) - **Implemented** @@ -715,7 +715,7 @@ Refer to [`IMPLEMENTATION_CHECKLIST.md`](src/Docs/IMPLEMENTATION_CHECKLIST.md) f ZipDrive is considered complete when: - [x] Caching layer fully implemented with 80%+ test coverage (149 tests including chunked extraction) - [x] ZIP reader implemented and tested (33 tests, including encoding detection) -- [x] DokanNet adapter functional (mount/unmount works) +- [x] WinFsp adapter functional (mount/unmount works) - [x] CLI accepts arguments and mounts drives - [x] Dual-tier cache coordinator with strategy-owned materialization (6 tests) - [x] OpenTelemetry observability (metrics, tracing, Aspire Dashboard) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5b4db4c..b02a15c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ - + diff --git a/src/Docs/WINFSP_MIGRATION_PLAN.md b/src/Docs/WINFSP_MIGRATION_PLAN.md new file mode 100644 index 0000000..6294482 --- /dev/null +++ b/src/Docs/WINFSP_MIGRATION_PLAN.md @@ -0,0 +1,781 @@ +# Dokany → WinFsp Migration Plan + +## 1. Executive Summary + +Migrate ZipDrive's virtual file system driver layer from **DokanNet** (Dokany) to **WinFsp** (`winfsp.net` NuGet package) while introducing a **zero-heap-allocation read path** using `IMemoryOwner` and memory-mapped files to return pointers directly to the WinFsp kernel buffer. + +### Goals + +| Goal | Description | +|------|-------------| +| **Driver swap** | Replace DokanNet with WinFsp (`winfsp.net`) — lower kernel overhead, actively maintained | +| **Zero-heap reads** | Eliminate per-read `ArrayPool` rent/return and intermediate `byte[]` copies | +| **IMemoryOwner** | Expose cached file content as `IMemoryOwner` with pinned memory for pointer access | +| **Memory-mapped storage** | Replace `byte[]` (GC heap) in `MemoryStorageStrategy` with anonymous `MemoryMappedFile` | +| **Direct pointer copy** | In the WinFsp `Read` callback, copy directly from mmap pointer → WinFsp `IntPtr` buffer | + +### Read-Path Before vs After + +``` +BEFORE (Dokany): + WinFsp IntPtr ← CopyTo ← byte[] (ArrayPool) ← Stream.Read ← MemoryStream(byte[]) + Allocations: 1 ArrayPool rent per read + MemoryStream per borrow + +AFTER (WinFsp + mmap): + WinFsp IntPtr ← Unsafe.CopyBlock ← mmap pointer (IMemoryOwner) + Allocations: 0 per read (only on cache miss: mmap creation) +``` + +--- + +## 2. Architecture Impact Analysis + +### 2.1 Layers Affected + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Layer │ Impact │ Changes Required │ +├─────────────────────────────────┼───────────┼─────────────────────────┤ +│ Domain (Abstractions) │ MODERATE │ New IMemoryOwner-based │ +│ │ │ ReadFileAsync overload │ +│ │ │ in IVirtualFileSystem │ +├─────────────────────────────────┼───────────┼─────────────────────────┤ +│ Application (VFS orchestration) │ MODERATE │ Add zero-copy read path │ +│ │ │ through FileContentCache│ +├─────────────────────────────────┼───────────┼─────────────────────────┤ +│ Infrastructure.Caching │ MAJOR │ New MmapStorageStrategy,│ +│ │ │ IMemoryOwner handles │ +├─────────────────────────────────┼───────────┼─────────────────────────┤ +│ Infrastructure.FileSystem │ REPLACE │ New WinFsp adapter + │ +│ │ │ hosted service │ +├─────────────────────────────────┼───────────┼─────────────────────────┤ +│ Presentation (CLI) │ MINOR │ DI registration swap, │ +│ │ │ telemetry meter rename │ +└─────────────────────────────────┴───────────┴─────────────────────────┘ +``` + +### 2.2 Unchanged Layers + +- **Infrastructure.Archives.Zip** — extraction logic unchanged +- **Infrastructure.Archives.Rar** — extraction logic unchanged +- **ChunkedDiskStorageStrategy** — large file disk path unchanged (already uses file-backed storage) +- **GenericCache** — borrow/return/eviction logic unchanged +- **ArchiveChangeConsolidator** — FileSystemWatcher logic unchanged + +--- + +## 3. Phase 1: WinFsp File System Adapter + +### 3.1 Package Changes + +| Action | Package | Version | +|--------|---------|---------| +| **Remove** | `DokanNet` (2.3.0.3) | — | +| **Add** | `winfsp.net` (2.1.25156) | Central in `Directory.Packages.props` | + +### 3.2 New: `WinFspFileSystemAdapter` (replaces `DokanFileSystemAdapter`) + +Extends `Fsp.FileSystemBase` instead of implementing `IDokanOperations2`. + +#### API Mapping: DokanNet → WinFsp + +| DokanNet Method | WinFsp Method | Notes | +|-----------------|---------------|-------| +| `CreateFile` | `Open` + `GetSecurityByName` | WinFsp separates security check from open | +| `ReadFile` | `Read` | `IntPtr Buffer` instead of `NativeMemory` | +| `FindFiles` | `ReadDirectory` | Uses `DirectoryBuffer` pattern | +| `FindFilesWithPattern` | (handled by WinFsp kernel) | WinFsp does pattern matching in kernel | +| `GetFileInformation` | `GetFileInfo` | Returns `Fsp.Interop.FileInfo` struct | +| `GetVolumeInformation` | `GetVolumeInfo` | Returns `Fsp.Interop.VolumeInfo` struct | +| `GetDiskFreeSpace` | (in `GetVolumeInfo`) | Combined into VolumeInfo | +| `GetFileSecurity` | `GetSecurity` | | +| `Mounted` | `Mounted` | | +| `Unmounted` | `Unmounted` | | +| `Cleanup` | `Cleanup` | | +| `CloseFile` | `Close` | | +| `WriteFile` | `Write` | → STATUS_ACCESS_DENIED | +| `DeleteFile` | — | Not implemented (read-only) | +| `MoveFile` | `Rename` | → STATUS_ACCESS_DENIED | +| All write ops | — | → STATUS_ACCESS_DENIED | + +#### Read Method Implementation (Zero-Heap) + +```csharp +public override Int32 Read( + Object FileNode, + Object FileDesc, + IntPtr Buffer, // WinFsp provides this — caller's buffer + UInt64 Offset, + UInt32 Length, + out UInt32 BytesTransferred) +{ + var ctx = (FileContext)FileNode; + + // ZERO-HEAP PATH: borrow IMemoryOwner from cache, + // copy directly from pinned mmap pointer → WinFsp IntPtr buffer + using ICacheHandle> handle = _vfs.BorrowFileContentAsync( + ctx.Path, (long)Offset, (int)Length).GetAwaiter().GetResult(); + + int bytesRead = handle.Value.Memory.Length; + if (bytesRead > 0) + { + unsafe + { + using var pin = handle.Value.Memory.Pin(); + System.Buffer.MemoryCopy( + pin.Pointer, + (void*)Buffer, + Length, + bytesRead); + } + } + + BytesTransferred = (uint)bytesRead; + return STATUS_SUCCESS; +} +``` + +**Key advantage**: No `ArrayPool.Rent`, no intermediate `byte[]`, no `Stream.Read` — single `MemoryCopy` from pinned mmap to kernel buffer. + +#### Open Method (File Context Pattern) + +WinFsp uses `Open` with `FileNode`/`FileDesc` pattern instead of DokanNet's stateless `CreateFile`: + +```csharp +public override Int32 Open( + String FileName, + UInt32 CreateOptions, + UInt32 GrantedAccess, + out Object FileNode, + out Object FileDesc, + out FileInfo FileInfo, + out String NormalizedName) +{ + // ShellMetadata short-circuit + if (_shortCircuitShellMetadata && ShellMetadataFilter.IsShellMetadataPath(FileName)) + { + FileNode = null; FileDesc = null; + FileInfo = default; NormalizedName = null; + return STATUS_OBJECT_NAME_NOT_FOUND; + } + + VfsFileInfo vfsInfo = _vfs.GetFileInfoAsync(FileName).GetAwaiter().GetResult(); + + FileNode = new FileContext { Path = FileName, IsDirectory = vfsInfo.IsDirectory }; + FileDesc = null; + FileInfo = ToWinFspFileInfo(vfsInfo); + NormalizedName = null; + + return STATUS_SUCCESS; +} +``` + +#### FileContext Class + +```csharp +internal sealed class FileContext +{ + public required string Path { get; init; } + public required bool IsDirectory { get; init; } +} +``` + +### 3.3 Status Code Mapping + +| DokanResult | WinFsp STATUS | Constant | +|-------------|---------------|----------| +| `DokanResult.Success` | `STATUS_SUCCESS` | 0 | +| `DokanResult.FileNotFound` | `STATUS_OBJECT_NAME_NOT_FOUND` | 0xC0000034 | +| `DokanResult.PathNotFound` | `STATUS_OBJECT_PATH_NOT_FOUND` | 0xC000003A | +| `DokanResult.AccessDenied` | `STATUS_ACCESS_DENIED` | 0xC0000022 | +| `DokanResult.NotImplemented` | `STATUS_INVALID_DEVICE_REQUEST` | 0xC0000010 | +| `DokanResult.InternalError` | `STATUS_UNEXPECTED_IO_ERROR` | 0xC00000E9 | + +--- + +## 4. Phase 2: WinFsp Hosted Service + +### 4.1 New: `WinFspHostedService` (replaces `DokanHostedService`) + +#### Mount Lifecycle Mapping + +| DokanHostedService | WinFspHostedService | +|--------------------|---------------------| +| `new Dokan(logger)` | — (no equivalent; logging via FileSystemHost) | +| `new DokanInstanceBuilder(_dokan)` | `new FileSystemHost(adapter)` | +| `.ConfigureOptions(opts => { ... })` | `host.SectorSize = 4096; host.Prefix = ...` | +| `dokanBuilder.Build(_adapter)` | `host.Mount(mountPoint)` | +| `_dokanInstance.WaitForFileSystemClosedAsync(...)` | `host.Mount()` blocks / use Task.Run | +| `_dokan.RemoveMountPoint(mp)` | `host.Unmount()` | +| `_dokanInstance.Dispose()` | `host.Dispose()` | +| `DllNotFoundException` (Dokany) | Check for winfsp DLL load failure | + +#### Configuration Mapping + +```csharp +// BEFORE (DokanNet): +var dokanBuilder = new DokanInstanceBuilder(_dokan) + .ConfigureOptions(options => + { + options.Options = DokanOptions.WriteProtection + | DokanOptions.FixedDrive + | DokanOptions.MountManager; + options.MountPoint = _mountSettings.MountPoint; + }); + +// AFTER (WinFsp): +var host = new FileSystemHost(_adapter); +host.FileSystemName = "NTFS"; // Same as current (Dokany #947 workaround) +host.Prefix = null; // Local mount +host.SectorSize = 4096; +host.SectorsPerAllocationUnit = 1; +host.VolumeCreationTime = (ulong)DateTime.Now.ToFileTimeUtc(); +host.VolumeSerialNumber = 0; +host.Mount( + _mountSettings.MountPoint, + null, // SecurityDescriptor + false, // Synchronized (false = async dispatch) + 0); // DebugLog +``` + +### 4.2 FileSystemWatcher Integration + +The existing `ArchiveChangeConsolidator` and `FileSystemWatcher` logic is **driver-independent**. The only change is the hosted service class name and mount/unmount calls. All watcher code, delta application, and reconciliation logic transfers unchanged. + +--- + +## 5. Phase 3: Zero-Heap IMemoryOwner Read Path + +### 5.1 New Domain Abstraction: `IBufferHandle` + +```csharp +/// +/// Zero-copy handle to cached file content for a specific (offset, length) region. +/// The caller receives a Memory slice that is valid until Dispose. +/// Backed by pinned memory-mapped file view — no heap allocation. +/// +public interface IBufferHandle : IDisposable +{ + /// + /// The file content bytes for the requested region. + /// Valid until this handle is disposed. + /// + ReadOnlyMemory Data { get; } + + /// + /// Actual number of bytes available (may be less than requested at EOF). + /// + int BytesRead { get; } +} +``` + +### 5.2 Extended IVirtualFileSystem + +```csharp +public interface IVirtualFileSystem +{ + // ... existing methods ... + + /// + /// Reads file content with zero-heap allocation. + /// Returns a handle to pinned memory-mapped data that can be copied + /// directly to a native buffer via pointer. + /// + Task ReadFileDirectAsync( + string path, + long offset, + int length, + CancellationToken cancellationToken = default); +} +``` + +### 5.3 Extended IFileContentCache + +```csharp +public interface IFileContentCache +{ + // ... existing ReadAsync (byte[] buffer) ... + + /// + /// Zero-copy read: returns a handle to the cached data region. + /// The data is backed by a memory-mapped view and is valid until the handle is disposed. + /// + Task ReadDirectAsync( + string archivePath, + string formatId, + ArchiveEntryInfo entry, + string internalPath, + string cacheKey, + long offset, + int length, + CancellationToken cancellationToken = default); +} +``` + +### 5.4 Implementation: `MmapBufferHandle` + +```csharp +internal sealed class MmapBufferHandle : IBufferHandle +{ + private readonly ICacheHandle _cacheHandle; + private readonly ReadOnlyMemory _slice; + + public MmapBufferHandle( + ICacheHandle cacheHandle, + long offset, + int bytesRead) + { + _cacheHandle = cacheHandle; + _slice = cacheHandle.Value.GetMemory(offset, bytesRead); + BytesRead = bytesRead; + } + + public ReadOnlyMemory Data => _slice; + public int BytesRead { get; } + + public void Dispose() => _cacheHandle.Dispose(); +} +``` + +--- + +## 6. Phase 4: Memory-Mapped File Storage Strategy + +### 6.1 New: `MmapStorageStrategy` (replaces `MemoryStorageStrategy`) + +```csharp +/// +/// Stores file content in anonymous memory-mapped files. +/// Zero-heap: data lives outside the GC heap in virtual memory pages +/// managed by the OS VMM. Read access via pointer (IMemoryOwner). +/// +public sealed class MmapStorageStrategy : IStorageStrategy +{ + public async Task MaterializeAsync( + Func>> factory, + CancellationToken cancellationToken) + { + await using var result = await factory(cancellationToken).ConfigureAwait(false); + long size = result.SizeBytes; + + // Create anonymous memory-mapped file (no backing disk file) + var mmf = MemoryMappedFile.CreateNew( + mapName: null, + capacity: size, + MemoryMappedFileAccess.ReadWrite); + + // Copy decompressed stream → mmap view + using (var accessor = mmf.CreateViewStream(0, size, MemoryMappedFileAccess.Write)) + { + await result.Value.CopyToAsync(accessor, cancellationToken) + .ConfigureAwait(false); + } + + var entry = new MmapStorageEntry(mmf, (int)size); + return new StoredEntry(entry, size); + } + + public MmapStorageEntry Retrieve(StoredEntry stored) + => (MmapStorageEntry)stored.Data; + + public void Dispose(StoredEntry stored) + { + var entry = (MmapStorageEntry)stored.Data; + entry.Dispose(); + } + + public bool RequiresAsyncCleanup => false; +} +``` + +### 6.2 New: `MmapStorageEntry` + +```csharp +/// +/// Wraps a MemoryMappedFile with a read-only MemoryMappedViewAccessor +/// for zero-copy pointer access to cached file content. +/// +internal sealed class MmapStorageEntry : IDisposable +{ + private readonly MemoryMappedFile _mmf; + private readonly MemoryMappedViewAccessor _accessor; + private readonly unsafe byte* _pointer; + private readonly int _length; + + public MmapStorageEntry(MemoryMappedFile mmf, int length) + { + _mmf = mmf; + _length = length; + _accessor = mmf.CreateViewAccessor(0, length, MemoryMappedFileAccess.Read); + + // Pin the view and get a raw pointer + unsafe + { + byte* ptr = null; + _accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); + _pointer = ptr + _accessor.PointerOffset; + } + } + + public int Length => _length; + + /// + /// Returns a Memory slice for the given (offset, length) region. + /// Backed by the mmap pointer — no heap allocation. + /// + public ReadOnlyMemory GetMemory(long offset, int length) + { + int actualOffset = (int)Math.Min(offset, _length); + int actualLength = Math.Min(length, _length - actualOffset); + + // Use MemoryManager subclass that wraps the raw pointer + return new MmapMemoryManager(_pointer, _length) + .Memory.Slice(actualOffset, actualLength); + } + + public void Dispose() + { + unsafe + { + _accessor.SafeMemoryMappedViewHandle.ReleasePointer(); + } + _accessor.Dispose(); + _mmf.Dispose(); + } +} +``` + +### 6.3 New: `MmapMemoryManager` + +```csharp +/// +/// MemoryManager that wraps a raw pointer from a memory-mapped view. +/// Enables creating Memory / Span over mmap'd memory without copying. +/// +internal sealed unsafe class MmapMemoryManager : MemoryManager +{ + private readonly byte* _pointer; + private readonly int _length; + + public MmapMemoryManager(byte* pointer, int length) + { + _pointer = pointer; + _length = length; + } + + public override Span GetSpan() => new(_pointer, _length); + + public override MemoryHandle Pin(int elementIndex = 0) + => new(_pointer + elementIndex); + + public override void Unpin() { /* mmap is always "pinned" */ } + + protected override void Dispose(bool disposing) { /* Lifetime managed by MmapStorageEntry */ } +} +``` + +### 6.4 Storage Strategy Routing + +| File Size | Current Strategy | New Strategy | +|-----------|-----------------|--------------| +| < 50 MB | `MemoryStorageStrategy` (byte[] on GC heap) | `MmapStorageStrategy` (anonymous mmap, off-heap) | +| ≥ 50 MB | `ChunkedDiskStorageStrategy` (sparse file) | Unchanged (already file-backed) | + +For ChunkedDiskStorageStrategy, a future optimization could mmap the sparse backing file for zero-copy reads of already-extracted chunks, but this is deferred to Phase 5. + +--- + +## 7. Phase 5: ChunkedDisk Zero-Copy (Future) + +For large files (≥ 50MB) currently served by `ChunkedDiskStorageStrategy`: + +``` +CURRENT: ChunkedStream → FileStream.ReadAsync → byte[] → copy to WinFsp IntPtr +FUTURE: MemoryMappedFile over sparse file → pointer → copy to WinFsp IntPtr +``` + +This requires: +- `ChunkedStream` replaced with `ChunkedMmapView` that mmaps the backing sparse file +- Re-mmap on chunk completion (or use a single mmap and rely on OS page faults) +- Careful coordination with background extraction task + +**Deferred**: The chunk-sync complexity makes this a separate effort. + +--- + +## 8. End-to-End Read Flow (After Migration) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Zero-Heap Read Flow (WinFsp + mmap) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Windows ReadFile → WinFsp kernel driver → user-mode dispatch │ +│ ↓ │ +│ 2. WinFspFileSystemAdapter.Read(FileNode, IntPtr Buffer, Offset, Length) │ +│ • FileNode carries path from Open() │ +│ • IntPtr Buffer is caller's kernel buffer (no allocation) │ +│ ↓ │ +│ 3. IVirtualFileSystem.ReadFileDirectAsync(path, offset, length) │ +│ • Path resolution (archive + internalPath) │ +│ • ArchiveGuard (ref counting) │ +│ ↓ │ +│ 4. FileContentCache.ReadDirectAsync(...) │ +│ • Routes to MmapStorageStrategy (small) or ChunkedDisk (large) │ +│ • BorrowAsync → returns ICacheHandle │ +│ ↓ │ +│ 5. Cache HIT: │ +│ • MmapStorageEntry.GetMemory(offset, length) → ReadOnlyMemory │ +│ • Backed by mmap pointer — no allocation │ +│ Cache MISS: │ +│ • Factory → IArchiveEntryExtractor.ExtractAsync → decompressed stream │ +│ • MmapStorageStrategy.MaterializeAsync: │ +│ → MemoryMappedFile.CreateNew(capacity) │ +│ → CopyToAsync(mmapViewStream) │ +│ → Return MmapStorageEntry with pointer │ +│ ↓ │ +│ 6. WinFspFileSystemAdapter.Read() copies: │ +│ • Memory.Pin() → MemoryHandle.Pointer │ +│ • Buffer.MemoryCopy(mmapPtr → winfspIntPtr, length) │ +│ • Single memcpy, zero GC allocation │ +│ ↓ │ +│ 7. Return STATUS_SUCCESS with BytesTransferred │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +ALLOCATION PROFILE (per read, cache hit): + • Managed heap: 0 bytes (no ArrayPool, no byte[], no MemoryStream) + • Stack: ~64 bytes (MmapBufferHandle struct, Span, MemoryHandle) + • OS VMM: mmap pages already resident (created on cache miss only) +``` + +--- + +## 9. Concurrency Considerations + +### 9.1 Mmap Thread Safety + +- `MemoryMappedViewAccessor` is thread-safe for concurrent reads +- Multiple WinFsp Read callbacks can safely read from the same mmap view +- No per-read locking needed (read-only after materialization) +- `AcquirePointer`/`ReleasePointer` balanced in constructor/Dispose + +### 9.2 Cache Borrow/Return + +- Existing `GenericCache` borrow/return pattern protects `MmapStorageEntry` from eviction + while any `IBufferHandle` is outstanding +- `MmapStorageEntry.Dispose()` only called when `RefCount == 0` (all handles returned) +- This prevents releasing mmap pointer while reads are in progress + +### 9.3 Formal Verification + +- Existing TLA+ specs (`GenericCache.tla`, `ChunkedExtraction.tla`) remain valid +- `MmapStorageStrategy` uses the same `StoredEntry` / `GenericCache` lifecycle +- No new concurrency primitives introduced + +--- + +## 10. Dependency Changes + +### 10.1 `Directory.Packages.props` + +```xml + + + + + +``` + +### 10.2 `ZipDrive.Infrastructure.FileSystem.csproj` + +```xml + + + + + +``` + +### 10.3 `ZipDrive.Cli.csproj` + +No direct DokanNet/WinFsp dependency (only via Infrastructure.FileSystem project reference). + +--- + +## 11. Telemetry Changes + +| Current | New | +|---------|-----| +| Meter: `ZipDrive.Dokan` | Meter: `ZipDrive.FileSystem` | +| Histogram: `dokan.read_duration` | Histogram: `fs.read_duration` | +| OTel AddMeter: `"ZipDrive.Dokan"` | OTel AddMeter: `"ZipDrive.FileSystem"` | +| OTel AddSource: `"ZipDrive.Dokan"` | OTel AddSource: `"ZipDrive.FileSystem"` | + +--- + +## 12. DI Registration Changes + +```csharp +// BEFORE: +services.AddSingleton(); +services.AddHostedService(); + +// AFTER: +services.AddSingleton(); +services.AddHostedService(); +``` + +--- + +## 13. Configuration Changes + +### `appsettings.jsonc` + +```jsonc +// No changes needed — Mount:MountPoint and Mount:ArchiveDirectory are driver-independent. +// WinFsp accepts the same drive letter format (e.g., "R:\"). +``` + +### User-Facing Changes + +| Aspect | Before | After | +|--------|--------|-------| +| Driver requirement | Dokany v2.3.1.1000 | WinFsp v2.0+ | +| Install URL | github.com/dokan-dev/dokany | winfsp.dev/rel | +| DLL dependency | `dokan2.dll` | `winfsp-x64.dll` | +| Error message (missing driver) | "Dokany driver is not installed" | "WinFsp driver is not installed" | + +--- + +## 14. Test Impact + +### 14.1 Tests That Need Updates + +| Test File | Change | +|-----------|--------| +| `ArchiveChangeConsolidatorTests.cs` | **None** — driver-independent | +| `UserNoticeTests.cs` | **None** — driver-independent | +| Integration tests | Update any DokanNet-specific assertions | + +### 14.2 New Tests Required + +| Test | Description | +|------|-------------| +| `MmapStorageStrategyTests` | Materialize → Retrieve → verify data integrity | +| `MmapStorageEntryTests` | GetMemory at various offsets, thread-safety | +| `MmapMemoryManagerTests` | Pin/Unpin, Span correctness | +| `WinFspFileSystemAdapterTests` | Read → verify BytesTransferred | +| `MmapBufferHandleTests` | Borrow → read → dispose lifecycle | + +### 14.3 Build Validation + +```bash +dotnet build ZipDrive.slnx # Must compile +dotnet test # All 450+ tests must pass +``` + +Note: WinFsp tests may need the WinFsp driver installed. Tests that exercise the adapter directly (without mounting) can use mock VFS. + +--- + +## 15. Migration Checklist + +### Phase 1: WinFsp Adapter (Infrastructure.FileSystem) + +- [ ] Add `winfsp.net` to `Directory.Packages.props` +- [ ] Remove `DokanNet` from `Directory.Packages.props` +- [ ] Update `ZipDrive.Infrastructure.FileSystem.csproj` packages +- [ ] Create `FileContext.cs` (path + isDirectory) +- [ ] Create `WinFspFileSystemAdapter.cs` (extends `FileSystemBase`) + - [ ] `GetSecurityByName` — path existence check + - [ ] `Open` — create FileContext + - [ ] `Close` — dispose FileContext + - [ ] `Read` — zero-heap read via IBufferHandle + - [ ] `GetFileInfo` — map VfsFileInfo → Fsp.Interop.FileInfo + - [ ] `ReadDirectory` — list directory entries + - [ ] `GetVolumeInfo` — map VfsVolumeInfo → Fsp.Interop.VolumeInfo + - [ ] Write operations → STATUS_ACCESS_DENIED +- [ ] Create `WinFspHostedService.cs` (extends BackgroundService) + - [ ] Mount lifecycle (FileSystemHost) + - [ ] Shutdown lifecycle + - [ ] Port FileSystemWatcher integration + - [ ] Port error handling (missing driver, mount failure) +- [ ] Rename `DokanTelemetry` → `FileSystemTelemetry` +- [ ] Delete `DokanFileSystemAdapter.cs` +- [ ] Delete `DokanHostedService.cs` + +### Phase 2: Zero-Heap Read Path (Domain + Caching) + +- [ ] Create `IBufferHandle` interface in Domain +- [ ] Add `ReadFileDirectAsync` to `IVirtualFileSystem` +- [ ] Implement in `ArchiveVirtualFileSystem` +- [ ] Add `ReadDirectAsync` to `IFileContentCache` +- [ ] Implement in `FileContentCache` + +### Phase 3: Memory-Mapped Storage (Caching) + +- [ ] Create `MmapMemoryManager.cs` +- [ ] Create `MmapStorageEntry.cs` +- [ ] Create `MmapStorageStrategy.cs` (implements `IStorageStrategy`) +- [ ] Create `MmapBufferHandle.cs` +- [ ] Update `FileContentCache` constructor to use `MmapStorageStrategy` for memory tier +- [ ] Verify existing `byte[]`-based `ReadAsync` still works (backward compat) + +### Phase 4: DI + CLI Updates + +- [ ] Update `Program.cs` DI registrations +- [ ] Update OTel meter/source names +- [ ] Update error messages (Dokany → WinFsp) +- [ ] Update `appsettings.jsonc` comments if needed + +### Phase 5: Tests + Validation + +- [ ] Write `MmapStorageStrategyTests` +- [ ] Write `MmapStorageEntryTests` +- [ ] Write `WinFspFileSystemAdapter` unit tests +- [ ] Run full test suite +- [ ] Manual smoke test (mount archive, read file from Explorer) + +### Phase 6: Documentation + +- [ ] Update `CLAUDE.md` (driver requirements, build commands) +- [ ] Update `README.md` (install instructions) +- [ ] Update `VFS_ARCHITECTURE_DESIGN.md` (new read flow diagram) +- [ ] Archive this plan (move to completed) + +--- + +## 16. Risk Analysis + +| Risk | Mitigation | +|------|------------| +| WinFsp .NET API is .NET Standard 2.0 (boxing, no Span) | The Read callback uses IntPtr — we use unsafe pointer directly. Boxing only on FileNode/FileDesc (one alloc per Open, not per Read). | +| WinFsp driver not installed on user machines | Clear error message with download link. Same UX as current Dokany requirement. | +| `MemoryMappedFile.CreateNew` may fail for very large files | Keep `MmapStorageStrategy` for small files only (< 50MB). Large files continue using `ChunkedDiskStorageStrategy`. | +| mmap pointer lifetime (use-after-free) | Existing `RefCount` borrow/return pattern prevents eviction while handle is active. Same guarantees as current `MemoryStream` pattern. | +| Async/sync mismatch (WinFsp callbacks are sync, VFS is async) | Use `.GetAwaiter().GetResult()` as current Dokan adapter does. WinFsp dispatches on thread pool, so blocking is acceptable. | +| `unsafe` code required for mmap pointer access | Already have `AllowUnsafeBlocks` in FileSystem csproj. Add to Caching csproj. | + +--- + +## 17. Performance Expectations + +| Metric | Current (Dokany) | Expected (WinFsp + mmap) | +|--------|-------------------|--------------------------| +| Read latency (cache hit, small file) | ~50μs (ArrayPool rent + Stream.Read + copy) | ~5μs (single memcpy from mmap) | +| GC pressure per read | 1 ArrayPool rent/return + MemoryStream alloc | 0 GC allocations | +| Throughput (sequential reads) | Limited by copy chain | Limited by memcpy bandwidth | +| First-byte latency (cache miss) | ~50ms (extraction) | ~50ms (extraction, unchanged) | +| Memory overhead per cached file | byte[] on GC heap (Gen2 promotion) | mmap pages (OS VMM, no GC pressure) | + +--- + +## 18. Rollback Plan + +If WinFsp migration encounters blocking issues: + +1. Both adapters can coexist in the codebase (different namespaces) +2. DI registration can be switched via configuration flag +3. `MemoryStorageStrategy` (byte[]) remains as fallback if mmap has issues +4. All existing tests remain valid against the original code path diff --git a/src/ZipDrive.Application/Services/ArchiveVirtualFileSystem.cs b/src/ZipDrive.Application/Services/ArchiveVirtualFileSystem.cs index 0194314..b9e4e38 100644 --- a/src/ZipDrive.Application/Services/ArchiveVirtualFileSystem.cs +++ b/src/ZipDrive.Application/Services/ArchiveVirtualFileSystem.cs @@ -142,8 +142,8 @@ public async Task MountSingleFileAsync(string filePath, CancellationToken /// /// Hard stop: clears archive nodes without draining. In-flight operations may /// call Exit() on detached nodes (safe — operates on the object, not the dictionary). - /// This is acceptable because StopAsync in DokanHostedService removes the Dokan mount - /// point first, which stops new Dokan callbacks from arriving. + /// This is acceptable because StopAsync in WinFspHostedService removes the WinFsp mount + /// point first, which stops new file system callbacks from arriving. /// public Task UnmountAsync(CancellationToken cancellationToken = default) { diff --git a/src/ZipDrive.Cli/Program.cs b/src/ZipDrive.Cli/Program.cs index 88b3c0c..a415677 100644 --- a/src/ZipDrive.Cli/Program.cs +++ b/src/ZipDrive.Cli/Program.cs @@ -109,7 +109,7 @@ .WithMetrics(m => m .AddMeter("ZipDrive.Caching") .AddMeter("ZipDrive.Zip") - .AddMeter("ZipDrive.Dokan") + .AddMeter("ZipDrive.FileSystem") .AddRuntimeInstrumentation() .AddProcessInstrumentation() .AddOtlpExporter((exporterOptions, readerOptions) => @@ -123,7 +123,7 @@ .WithTracing(t => t .AddSource("ZipDrive.Caching") .AddSource("ZipDrive.Zip") - .AddSource("ZipDrive.Dokan") + .AddSource("ZipDrive.FileSystem") .AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint))); } else @@ -169,12 +169,12 @@ // Cache maintenance (periodic eviction + cleanup) services.AddHostedService(); - // VFS and Dokan + // VFS and WinFsp services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); - services.AddSingleton(); - services.AddHostedService(); + services.AddSingleton(); + services.AddHostedService(); }); var host = builder.Build(); diff --git a/src/ZipDrive.Infrastructure.Caching/ChunkedStream.cs b/src/ZipDrive.Infrastructure.Caching/ChunkedStream.cs index 9d6bfb5..7399552 100644 --- a/src/ZipDrive.Infrastructure.Caching/ChunkedStream.cs +++ b/src/ZipDrive.Infrastructure.Caching/ChunkedStream.cs @@ -105,8 +105,8 @@ public override async Task ReadAsync(byte[] buffer, int offset, int count, public override int Read(byte[] buffer, int offset, int count) { - // DokanNet may call Stream.Read() synchronously. - // Safe: extraction uses IOCP threads (async I/O), DokanNet has its own thread pool. + // WinFsp may call Stream.Read() synchronously. + // Safe: extraction uses IOCP threads (async I/O), WinFsp has its own thread pool. return ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None) .AsTask().GetAwaiter().GetResult(); } diff --git a/src/ZipDrive.Infrastructure.FileSystem/DokanFileSystemAdapter.cs b/src/ZipDrive.Infrastructure.FileSystem/DokanFileSystemAdapter.cs deleted file mode 100644 index 9a98cb4..0000000 --- a/src/ZipDrive.Infrastructure.FileSystem/DokanFileSystemAdapter.cs +++ /dev/null @@ -1,405 +0,0 @@ -using System.Buffers; -using System.Diagnostics; -using System.Runtime.Versioning; -using System.Security.AccessControl; -using DokanNet; -using LTRData.Extensions.Native.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using ZipDrive.Domain.Abstractions; -using ZipDrive.Domain.Configuration; -using ZipDrive.Domain.Exceptions; -using ZipDrive.Domain.Models; - -namespace ZipDrive.Infrastructure.FileSystem; - -/// -/// Thin adapter translating DokanNet IDokanOperations2 calls to IVirtualFileSystem. -/// Read-only: all write operations return AccessDenied. -/// -[SupportedOSPlatform("windows")] -public sealed class DokanFileSystemAdapter : IDokanOperations2 -{ - private readonly IVirtualFileSystem _vfs; - private readonly ILogger _logger; - private readonly bool _shortCircuitShellMetadata; - - public DokanFileSystemAdapter(IVirtualFileSystem vfs, IOptions mountSettings, ILogger logger) - { - _vfs = vfs; - _logger = logger; - _shortCircuitShellMetadata = mountSettings.Value.ShortCircuitShellMetadata; - } - - public int DirectoryListingTimeoutResetIntervalMs => 0; - - public NtStatus CreateFile( - ReadOnlyNativeMemory fileName, DokanNet.FileAccess access, FileShare share, - FileMode mode, FileOptions options, FileAttributes attributes, ref DokanFileInfo info) - { - // Short-circuit Windows shell metadata probes before allocating a string - if (_shortCircuitShellMetadata && ShellMetadataFilter.IsShellMetadataPath(fileName.Span)) - { - if (_logger.IsEnabled(LogLevel.Debug)) - _logger.LogDebug("CreateFile: short-circuit shell metadata: {Path}", fileName.Span.ToString()); - return DokanResult.FileNotFound; - } - - string path = fileName.Span.ToString(); - _logger.LogDebug("CreateFile: {Path} mode={Mode} access={Access}", path, mode, access); - - // Reject any create/write modes - if (mode is FileMode.CreateNew or FileMode.Create or FileMode.Append) - return DokanResult.AccessDenied; - - try - { - bool isDir = _vfs.DirectoryExistsAsync(path).GetAwaiter().GetResult(); - if (isDir) - { - info.IsDirectory = true; - return DokanResult.Success; - } - - bool isFile = _vfs.FileExistsAsync(path).GetAwaiter().GetResult(); - if (isFile) - { - info.IsDirectory = false; - return DokanResult.Success; - } - - return info.IsDirectory ? DokanResult.PathNotFound : DokanResult.FileNotFound; - } - catch (VfsFileNotFoundException) { return DokanResult.FileNotFound; } - catch (VfsDirectoryNotFoundException) { return DokanResult.PathNotFound; } - catch (VfsAccessDeniedException) { return DokanResult.AccessDenied; } - catch (Exception ex) - { - _logger.LogError(ex, "CreateFile error: {Path}", path); - return DokanResult.InternalError; - } - } - - public NtStatus ReadFile( - ReadOnlyNativeMemory fileName, NativeMemory buffer, - out int bytesRead, long offset, ref DokanFileInfo info) - { - string path = fileName.Span.ToString(); - _logger.LogDebug("ReadFile: {Path} offset={Offset} length={Length}", path, offset, buffer.Span.Length); - bytesRead = 0; - long startTimestamp = Stopwatch.GetTimestamp(); - - int requestedLength = buffer.Span.Length; - byte[] rentedArray = ArrayPool.Shared.Rent(requestedLength); - try - { - // ArrayPool may return a larger array than requested. Cap bytesRead - // to the native buffer size and copy only valid bytes to avoid - // leaking stale ArrayPool data into the Dokan native buffer. - int read = _vfs.ReadFileAsync(path, rentedArray, offset).GetAwaiter().GetResult(); - bytesRead = Math.Min(read, requestedLength); - - if (bytesRead > 0) - rentedArray.AsSpan(0, bytesRead).CopyTo(buffer.Span); - - DokanTelemetry.ReadDuration.Record( - Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, - new KeyValuePair("result", "success")); - - return DokanResult.Success; - } - catch (VfsFileNotFoundException) - { - return DokanResult.FileNotFound; - } - catch (VfsAccessDeniedException) - { - return DokanResult.AccessDenied; - } - catch (Exception ex) - { - DokanTelemetry.ReadDuration.Record( - Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, - new KeyValuePair("result", "error")); - - _logger.LogError(ex, "ReadFile error: {Path}", path); - return DokanResult.InternalError; - } - finally - { - ArrayPool.Shared.Return(rentedArray); - } - } - - public NtStatus FindFiles( - ReadOnlyNativeMemory fileName, out IEnumerable files, - ref DokanFileInfo info) - { - string path = fileName.Span.ToString(); - _logger.LogDebug("FindFiles: {Path}", path); - - try - { - IReadOnlyList entries = _vfs.ListDirectoryAsync(path).GetAwaiter().GetResult(); - files = entries.Select(ConvertToFindFileInfo); - return DokanResult.Success; - } - catch (VfsDirectoryNotFoundException) - { - files = []; - return DokanResult.PathNotFound; - } - catch (Exception ex) - { - _logger.LogError(ex, "FindFiles error: {Path}", path); - files = []; - return DokanResult.InternalError; - } - } - - public NtStatus FindFilesWithPattern( - ReadOnlyNativeMemory fileName, ReadOnlyNativeMemory searchPattern, - out IEnumerable files, ref DokanFileInfo info) - { - string path = fileName.Span.ToString(); - string pattern = searchPattern.Span.ToString(); - _logger.LogDebug("FindFilesWithPattern: {Path} pattern={Pattern}", path, pattern); - try - { - IReadOnlyList entries = _vfs.ListDirectoryAsync(path).GetAwaiter().GetResult(); - files = entries - .Where(e => DokanHelper.DokanIsNameInExpression(pattern, e.Name, true)) - .Select(ConvertToFindFileInfo); - return DokanResult.Success; - } - catch (VfsDirectoryNotFoundException) - { - files = []; - return DokanResult.PathNotFound; - } - catch (Exception ex) - { - _logger.LogError(ex, "FindFilesWithPattern error: {Path} pattern={Pattern}", path, pattern); - files = []; - return DokanResult.InternalError; - } - } - - public NtStatus GetFileInformation( - ReadOnlyNativeMemory fileName, out ByHandleFileInformation fileInfo, - ref DokanFileInfo info) - { - string path = fileName.Span.ToString(); - _logger.LogDebug("GetFileInformation: {Path}", path); - fileInfo = default; - - try - { - VfsFileInfo vfsInfo = _vfs.GetFileInfoAsync(path).GetAwaiter().GetResult(); - fileInfo = new ByHandleFileInformation - { - Attributes = vfsInfo.Attributes | FileAttributes.ReadOnly, - CreationTime = vfsInfo.CreationTimeUtc, - LastAccessTime = vfsInfo.LastAccessTimeUtc, - LastWriteTime = vfsInfo.LastWriteTimeUtc, - Length = vfsInfo.SizeBytes - }; - return DokanResult.Success; - } - catch (VfsFileNotFoundException) - { - return DokanResult.FileNotFound; - } - catch (VfsDirectoryNotFoundException) - { - return DokanResult.PathNotFound; - } - catch (Exception ex) - { - _logger.LogError(ex, "GetFileInformation error: {Path}", path); - return DokanResult.InternalError; - } - } - - public NtStatus GetVolumeInformation( - NativeMemory volumeLabel, out FileSystemFeatures features, - NativeMemory fileSystemName, out uint maximumComponentLength, - ref uint volumeSerialNumber, ref DokanFileInfo info) - { - _logger.LogDebug("GetVolumeInformation"); - VfsVolumeInfo vol = _vfs.GetVolumeInfo(); - volumeLabel.SetString(vol.VolumeLabel); - // Report as "NTFS" so Windows path resolution works for elevated processes (Dokany #947). - // A custom name like "ZipDriveFS" causes "path does not exist" when launching EXEs that - // require administrator elevation from the mounted drive. - fileSystemName.SetString("NTFS"); - maximumComponentLength = 255; - features = FileSystemFeatures.CasePreservedNames - | FileSystemFeatures.UnicodeOnDisk - | FileSystemFeatures.ReadOnlyVolume; - return DokanResult.Success; - } - - public NtStatus GetDiskFreeSpace( - out long freeBytesAvailable, out long totalNumberOfBytes, - out long totalNumberOfFreeBytes, ref DokanFileInfo info) - { - _logger.LogDebug("GetDiskFreeSpace"); - VfsVolumeInfo vol = _vfs.GetVolumeInfo(); - freeBytesAvailable = 0; - totalNumberOfFreeBytes = 0; - totalNumberOfBytes = vol.TotalBytes; - return DokanResult.Success; - } - - public NtStatus GetFileSecurity( - ReadOnlyNativeMemory fileName, out FileSystemSecurity? security, - AccessControlSections sections, ref DokanFileInfo info) - { - _logger.LogDebug("GetFileSecurity: {Path}", fileName.Span.ToString()); - security = null; - return DokanResult.NotImplemented; - } - - public NtStatus Mounted(ReadOnlyNativeMemory mountPoint, ref DokanFileInfo info) - { - _logger.LogInformation("Drive mounted at {MountPoint}", mountPoint.Span.ToString()); - return DokanResult.Success; - } - - public NtStatus Unmounted(ref DokanFileInfo info) - { - _logger.LogInformation("Drive unmounted"); - return DokanResult.Success; - } - - // === No-op lifecycle methods === - - public void Cleanup(ReadOnlyNativeMemory fileName, ref DokanFileInfo info) - { - _logger.LogDebug("Cleanup: {Path}", fileName.Span.ToString()); - } - - public void CloseFile(ReadOnlyNativeMemory fileName, ref DokanFileInfo info) - { - _logger.LogDebug("CloseFile: {Path}", fileName.Span.ToString()); - } - - // === Read-only: all write ops return AccessDenied === - - public NtStatus WriteFile(ReadOnlyNativeMemory fileName, ReadOnlyNativeMemory buffer, - out int bytesWritten, long offset, ref DokanFileInfo info) - { - _logger.LogDebug("WriteFile: {Path}", fileName.Span.ToString()); - bytesWritten = 0; - return DokanResult.AccessDenied; - } - - public NtStatus DeleteFile(ReadOnlyNativeMemory fileName, ref DokanFileInfo info) - { - _logger.LogDebug("DeleteFile: {Path}", fileName.Span.ToString()); - return DokanResult.AccessDenied; - } - - public NtStatus DeleteDirectory(ReadOnlyNativeMemory fileName, ref DokanFileInfo info) - { - _logger.LogDebug("DeleteDirectory: {Path}", fileName.Span.ToString()); - return DokanResult.AccessDenied; - } - - public NtStatus MoveFile(ReadOnlyNativeMemory oldName, ReadOnlyNativeMemory newName, - bool replace, ref DokanFileInfo info) - { - _logger.LogDebug("MoveFile: {OldPath} -> {NewPath}", oldName.Span.ToString(), newName.Span.ToString()); - return DokanResult.AccessDenied; - } - - public NtStatus SetFileAttributes(ReadOnlyNativeMemory fileName, - FileAttributes attributes, ref DokanFileInfo info) - { - _logger.LogDebug("SetFileAttributes: {Path}", fileName.Span.ToString()); - return DokanResult.AccessDenied; - } - - public NtStatus SetFileTime(ReadOnlyNativeMemory fileName, - DateTime? creationTime, DateTime? lastAccessTime, DateTime? lastWriteTime, ref DokanFileInfo info) - { - _logger.LogDebug("SetFileTime: {Path}", fileName.Span.ToString()); - return DokanResult.AccessDenied; - } - - public NtStatus SetEndOfFile(ReadOnlyNativeMemory fileName, long length, ref DokanFileInfo info) - { - _logger.LogDebug("SetEndOfFile: {Path}", fileName.Span.ToString()); - return DokanResult.AccessDenied; - } - - public NtStatus SetAllocationSize(ReadOnlyNativeMemory fileName, long length, ref DokanFileInfo info) - { - _logger.LogDebug("SetAllocationSize: {Path}", fileName.Span.ToString()); - return DokanResult.AccessDenied; - } - - public NtStatus SetFileSecurity(ReadOnlyNativeMemory fileName, - FileSystemSecurity security, AccessControlSections sections, ref DokanFileInfo info) - { - _logger.LogDebug("SetFileSecurity: {Path}", fileName.Span.ToString()); - return DokanResult.AccessDenied; - } - - public NtStatus FlushFileBuffers(ReadOnlyNativeMemory fileName, ref DokanFileInfo info) - { - _logger.LogDebug("FlushFileBuffers: {Path}", fileName.Span.ToString()); - return DokanResult.Success; - } - - public NtStatus LockFile(ReadOnlyNativeMemory fileName, long offset, long length, ref DokanFileInfo info) - { - _logger.LogDebug("LockFile: {Path} offset={Offset} length={Length}", fileName.Span.ToString(), offset, length); - return DokanResult.NotImplemented; - } - - public NtStatus UnlockFile(ReadOnlyNativeMemory fileName, long offset, long length, ref DokanFileInfo info) - { - _logger.LogDebug("UnlockFile: {Path} offset={Offset} length={Length}", fileName.Span.ToString(), offset, length); - return DokanResult.NotImplemented; - } - - public NtStatus FindStreams(ReadOnlyNativeMemory fileName, - out IEnumerable streams, ref DokanFileInfo info) - { - _logger.LogDebug("FindStreams: {Path}", fileName.Span.ToString()); - streams = []; - return DokanResult.NotImplemented; - } - - // === Guarded async methods (test-facing API — no Dokan native types) === - - public Task GuardedReadFileAsync(string path, byte[] buffer, long offset, CancellationToken ct = default) - => _vfs.ReadFileAsync(path, buffer, offset, ct); - - public Task> GuardedListDirectoryAsync(string path, CancellationToken ct = default) - => _vfs.ListDirectoryAsync(path, ct); - - public Task GuardedGetFileInfoAsync(string path, CancellationToken ct = default) - => _vfs.GetFileInfoAsync(path, ct); - - public Task GuardedFileExistsAsync(string path, CancellationToken ct = default) - => _vfs.FileExistsAsync(path, ct); - - public Task GuardedDirectoryExistsAsync(string path, CancellationToken ct = default) - => _vfs.DirectoryExistsAsync(path, ct); - - // === Helpers === - - private static FindFileInformation ConvertToFindFileInfo(VfsFileInfo entry) => new() - { - FileName = entry.Name.AsMemory(), - Attributes = entry.Attributes | FileAttributes.ReadOnly, - CreationTime = entry.CreationTimeUtc, - LastAccessTime = entry.LastAccessTimeUtc, - LastWriteTime = entry.LastWriteTimeUtc, - Length = entry.SizeBytes - }; - -} diff --git a/src/ZipDrive.Infrastructure.FileSystem/FileContext.cs b/src/ZipDrive.Infrastructure.FileSystem/FileContext.cs new file mode 100644 index 0000000..dae925d --- /dev/null +++ b/src/ZipDrive.Infrastructure.FileSystem/FileContext.cs @@ -0,0 +1,14 @@ +using System.Runtime.Versioning; + +namespace ZipDrive.Infrastructure.FileSystem; + +/// +/// Per-open-handle context passed through WinFsp's FileNode/FileDesc parameters. +/// Carries the virtual path and directory flag resolved at Open time. +/// +[SupportedOSPlatform("windows")] +internal sealed class FileContext +{ + public required string Path { get; init; } + public required bool IsDirectory { get; init; } +} diff --git a/src/ZipDrive.Infrastructure.FileSystem/DokanTelemetry.cs b/src/ZipDrive.Infrastructure.FileSystem/FileSystemTelemetry.cs similarity index 51% rename from src/ZipDrive.Infrastructure.FileSystem/DokanTelemetry.cs rename to src/ZipDrive.Infrastructure.FileSystem/FileSystemTelemetry.cs index 32d5de3..950d1fd 100644 --- a/src/ZipDrive.Infrastructure.FileSystem/DokanTelemetry.cs +++ b/src/ZipDrive.Infrastructure.FileSystem/FileSystemTelemetry.cs @@ -1,21 +1,21 @@ -using System.Diagnostics; -using System.Diagnostics.Metrics; - -namespace ZipDrive.Infrastructure.FileSystem; - -/// -/// Static telemetry definitions for the Dokan file system adapter. -/// Uses System.Diagnostics.Metrics (no OTel dependency). -/// -internal static class DokanTelemetry -{ - internal const string MeterName = "ZipDrive.Dokan"; - - internal static readonly Meter Meter = new(MeterName); - - // === Histograms === - - internal static readonly Histogram ReadDuration = - Meter.CreateHistogram("dokan.read.duration", unit: "ms", - description: "Time to process a Dokan ReadFile request"); -} +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace ZipDrive.Infrastructure.FileSystem; + +/// +/// Static telemetry definitions for the file system adapter. +/// Uses System.Diagnostics.Metrics (no OTel dependency). +/// +internal static class FileSystemTelemetry +{ + internal const string MeterName = "ZipDrive.FileSystem"; + + internal static readonly Meter Meter = new(MeterName); + + // === Histograms === + + internal static readonly Histogram ReadDuration = + Meter.CreateHistogram("fs.read.duration", unit: "ms", + description: "Time to process a ReadFile request"); +} diff --git a/src/ZipDrive.Infrastructure.FileSystem/ShellMetadataFilter.cs b/src/ZipDrive.Infrastructure.FileSystem/ShellMetadataFilter.cs index 77b6ad7..6a4d9f7 100644 --- a/src/ZipDrive.Infrastructure.FileSystem/ShellMetadataFilter.cs +++ b/src/ZipDrive.Infrastructure.FileSystem/ShellMetadataFilter.cs @@ -2,7 +2,7 @@ namespace ZipDrive.Infrastructure.FileSystem; /// /// Identifies Windows shell metadata paths that never exist inside ZIP archives. -/// Short-circuiting these in the Dokan adapter avoids eagerly parsing ZIP Central +/// Short-circuiting these in the file system adapter avoids eagerly parsing ZIP Central /// Directories just to return FileNotFound for probes like \archive.zip\desktop.ini. /// public static class ShellMetadataFilter diff --git a/src/ZipDrive.Infrastructure.FileSystem/WinFspFileSystemAdapter.cs b/src/ZipDrive.Infrastructure.FileSystem/WinFspFileSystemAdapter.cs new file mode 100644 index 0000000..e6e8480 --- /dev/null +++ b/src/ZipDrive.Infrastructure.FileSystem/WinFspFileSystemAdapter.cs @@ -0,0 +1,518 @@ +using System.Buffers; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using Fsp; +using Fsp.Interop; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZipDrive.Domain.Abstractions; +using ZipDrive.Domain.Configuration; +using ZipDrive.Domain.Exceptions; +using ZipDrive.Domain.Models; +using FileInfo = Fsp.Interop.FileInfo; + +namespace ZipDrive.Infrastructure.FileSystem; + +/// +/// Thin adapter translating WinFsp FileSystemBase calls to IVirtualFileSystem. +/// Read-only: all write operations return STATUS_ACCESS_DENIED. +/// +[SupportedOSPlatform("windows")] +public sealed class WinFspFileSystemAdapter : FileSystemBase +{ + private readonly IVirtualFileSystem _vfs; + private readonly ILogger _logger; + private readonly bool _shortCircuitShellMetadata; + + public WinFspFileSystemAdapter( + IVirtualFileSystem vfs, + IOptions mountSettings, + ILogger logger) + { + _vfs = vfs; + _logger = logger; + _shortCircuitShellMetadata = mountSettings.Value.ShortCircuitShellMetadata; + } + + // === File Open / Close === + + public override Int32 GetSecurityByName( + String FileName, + out UInt32 FileAttributes, + ref Byte[] SecurityDescriptor) + { + // Short-circuit Windows shell metadata probes + if (_shortCircuitShellMetadata && ShellMetadataFilter.IsShellMetadataPath(FileName.AsSpan())) + { + FileAttributes = default; + return STATUS_OBJECT_NAME_NOT_FOUND; + } + + try + { + bool isDir = _vfs.DirectoryExistsAsync(FileName).GetAwaiter().GetResult(); + if (isDir) + { + FileAttributes = (UInt32)System.IO.FileAttributes.Directory + | (UInt32)System.IO.FileAttributes.ReadOnly; + return STATUS_SUCCESS; + } + + bool isFile = _vfs.FileExistsAsync(FileName).GetAwaiter().GetResult(); + if (isFile) + { + FileAttributes = (UInt32)System.IO.FileAttributes.ReadOnly; + return STATUS_SUCCESS; + } + + FileAttributes = default; + return STATUS_OBJECT_NAME_NOT_FOUND; + } + catch (VfsFileNotFoundException) + { + FileAttributes = default; + return STATUS_OBJECT_NAME_NOT_FOUND; + } + catch (VfsDirectoryNotFoundException) + { + FileAttributes = default; + return STATUS_OBJECT_PATH_NOT_FOUND; + } + catch (Exception ex) + { + _logger.LogError(ex, "GetSecurityByName error: {Path}", FileName); + FileAttributes = default; + return STATUS_UNEXPECTED_IO_ERROR; + } + } + + public override Int32 Open( + String FileName, + UInt32 CreateOptions, + UInt32 GrantedAccess, + out Object FileNode, + out Object FileDesc, + out FileInfo FileInfo, + out String NormalizedName) + { + FileNode = default!; + FileDesc = default!; + FileInfo = default; + NormalizedName = default!; + + // Short-circuit Windows shell metadata probes + if (_shortCircuitShellMetadata && ShellMetadataFilter.IsShellMetadataPath(FileName.AsSpan())) + { + _logger.LogDebug("Open: short-circuit shell metadata: {Path}", FileName); + return STATUS_OBJECT_NAME_NOT_FOUND; + } + + _logger.LogDebug("Open: {Path}", FileName); + + try + { + VfsFileInfo vfsInfo = _vfs.GetFileInfoAsync(FileName).GetAwaiter().GetResult(); + + FileNode = new FileContext + { + Path = FileName, + IsDirectory = vfsInfo.IsDirectory + }; + // WinFsp FileDesc is unused — we carry all state in FileNode (FileContext). + FileDesc = null!; + FileInfo = ToWinFspFileInfo(vfsInfo); + NormalizedName = null!; + + return STATUS_SUCCESS; + } + catch (VfsFileNotFoundException) + { + return STATUS_OBJECT_NAME_NOT_FOUND; + } + catch (VfsDirectoryNotFoundException) + { + return STATUS_OBJECT_PATH_NOT_FOUND; + } + catch (VfsAccessDeniedException) + { + return STATUS_ACCESS_DENIED; + } + catch (Exception ex) + { + _logger.LogError(ex, "Open error: {Path}", FileName); + return STATUS_UNEXPECTED_IO_ERROR; + } + } + + public override void Close(Object FileNode, Object FileDesc) + { + if (FileNode is FileContext ctx) + _logger.LogDebug("Close: {Path}", ctx.Path); + } + + // === Read === + + public override Int32 Read( + Object FileNode, + Object FileDesc, + IntPtr Buffer, + UInt64 Offset, + UInt32 Length, + out UInt32 BytesTransferred) + { + BytesTransferred = 0; + + if (FileNode is not FileContext ctx) + return STATUS_INVALID_PARAMETER; + + _logger.LogDebug("Read: {Path} offset={Offset} length={Length}", ctx.Path, Offset, Length); + long startTimestamp = Stopwatch.GetTimestamp(); + + byte[] rentedArray = ArrayPool.Shared.Rent((int)Length); + try + { + int read = _vfs.ReadFileAsync(ctx.Path, rentedArray, (long)Offset) + .GetAwaiter().GetResult(); + int bytesRead = Math.Min(read, (int)Length); + + if (bytesRead > 0) + Marshal.Copy(rentedArray, 0, Buffer, bytesRead); + + BytesTransferred = (uint)bytesRead; + + FileSystemTelemetry.ReadDuration.Record( + Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, + new KeyValuePair("result", "success")); + + return STATUS_SUCCESS; + } + catch (VfsFileNotFoundException) + { + return STATUS_OBJECT_NAME_NOT_FOUND; + } + catch (VfsAccessDeniedException) + { + return STATUS_ACCESS_DENIED; + } + catch (Exception ex) + { + FileSystemTelemetry.ReadDuration.Record( + Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds, + new KeyValuePair("result", "error")); + + _logger.LogError(ex, "Read error: {Path}", ctx.Path); + return STATUS_UNEXPECTED_IO_ERROR; + } + finally + { + ArrayPool.Shared.Return(rentedArray); + } + } + + // === Directory Listing === + + public override Boolean ReadDirectoryEntry( + Object FileNode, + Object FileDesc, + String Pattern, + String Marker, + ref Object Context, + out String FileName, + out FileInfo FileInfo) + { + if (FileNode is not FileContext ctx) + { + FileName = default!; + FileInfo = default; + return false; + } + + // Lazily populate directory entries on first call (Context == null) + if (Context is not IEnumerator enumerator) + { + try + { + IReadOnlyList entries = _vfs.ListDirectoryAsync(ctx.Path) + .GetAwaiter().GetResult(); + + // Add "." and ".." entries + var allEntries = new List(entries.Count + 2); + allEntries.Add(new VfsFileInfo + { + Name = ".", + FullPath = ctx.Path, + IsDirectory = true, + SizeBytes = 0, + Attributes = System.IO.FileAttributes.Directory | System.IO.FileAttributes.ReadOnly, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + LastAccessTimeUtc = DateTime.UtcNow + }); + allEntries.Add(new VfsFileInfo + { + Name = "..", + FullPath = ctx.Path, + IsDirectory = true, + SizeBytes = 0, + Attributes = System.IO.FileAttributes.Directory | System.IO.FileAttributes.ReadOnly, + CreationTimeUtc = DateTime.UtcNow, + LastWriteTimeUtc = DateTime.UtcNow, + LastAccessTimeUtc = DateTime.UtcNow + }); + allEntries.AddRange(entries); + + enumerator = allEntries.GetEnumerator(); + Context = enumerator; + + // Skip to marker if provided (WinFsp uses markers for pagination) + if (Marker is not null) + { + while (enumerator.MoveNext()) + { + if (string.Equals(enumerator.Current.Name, Marker, StringComparison.OrdinalIgnoreCase)) + break; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "ReadDirectoryEntry error: {Path}", ctx.Path); + FileName = default!; + FileInfo = default; + return false; + } + } + + if (enumerator.MoveNext()) + { + VfsFileInfo entry = enumerator.Current; + FileName = entry.Name; + FileInfo = ToWinFspFileInfo(entry); + return true; + } + + FileName = default!; + FileInfo = default; + return false; + } + + // === File Information === + + public override Int32 GetFileInfo( + Object FileNode, + Object FileDesc, + out FileInfo FileInfo) + { + FileInfo = default; + + if (FileNode is not FileContext ctx) + return STATUS_INVALID_PARAMETER; + + _logger.LogDebug("GetFileInfo: {Path}", ctx.Path); + + try + { + VfsFileInfo vfsInfo = _vfs.GetFileInfoAsync(ctx.Path).GetAwaiter().GetResult(); + FileInfo = ToWinFspFileInfo(vfsInfo); + return STATUS_SUCCESS; + } + catch (VfsFileNotFoundException) + { + return STATUS_OBJECT_NAME_NOT_FOUND; + } + catch (VfsDirectoryNotFoundException) + { + return STATUS_OBJECT_PATH_NOT_FOUND; + } + catch (Exception ex) + { + _logger.LogError(ex, "GetFileInfo error: {Path}", ctx.Path); + return STATUS_UNEXPECTED_IO_ERROR; + } + } + + // === Volume Information === + + public override Int32 GetVolumeInfo(out VolumeInfo VolumeInfo) + { + _logger.LogDebug("GetVolumeInfo"); + VfsVolumeInfo vol = _vfs.GetVolumeInfo(); + + VolumeInfo = default; + VolumeInfo.TotalSize = (ulong)vol.TotalBytes; + VolumeInfo.FreeSize = (ulong)vol.FreeBytes; + VolumeInfo.SetVolumeLabel(vol.VolumeLabel); + + return STATUS_SUCCESS; + } + + // === Lifecycle === + + public override Int32 Mounted(Object Host) + { + _logger.LogInformation("Drive mounted (WinFsp)"); + return STATUS_SUCCESS; + } + + public override void Unmounted(Object Host) + { + _logger.LogInformation("Drive unmounted (WinFsp)"); + } + + public override void Cleanup(Object FileNode, Object FileDesc, String FileName, UInt32 Flags) + { + _logger.LogDebug("Cleanup: {Path}", FileName); + } + + // === Read-only: all write ops return STATUS_ACCESS_DENIED === + + public override Int32 Create( + String FileName, + UInt32 CreateOptions, + UInt32 GrantedAccess, + UInt32 FileAttributes, + Byte[] SecurityDescriptor, + UInt64 AllocationSize, + out Object FileNode, + out Object FileDesc, + out FileInfo FileInfo, + out String NormalizedName) + { + FileNode = default!; + FileDesc = default!; + FileInfo = default; + NormalizedName = default!; + return STATUS_ACCESS_DENIED; + } + + public override Int32 Overwrite( + Object FileNode, + Object FileDesc, + UInt32 FileAttributes, + Boolean ReplaceFileAttributes, + UInt64 AllocationSize, + out FileInfo FileInfo) + { + FileInfo = default; + return STATUS_ACCESS_DENIED; + } + + public override Int32 Write( + Object FileNode, + Object FileDesc, + IntPtr Buffer, + UInt64 Offset, + UInt32 Length, + Boolean WriteToEndOfFile, + Boolean ConstrainedIo, + out UInt32 BytesTransferred, + out FileInfo FileInfo) + { + BytesTransferred = 0; + FileInfo = default; + return STATUS_ACCESS_DENIED; + } + + public override Int32 Flush( + Object FileNode, + Object FileDesc, + out FileInfo FileInfo) + { + FileInfo = default; + return STATUS_SUCCESS; + } + + public override Int32 SetBasicInfo( + Object FileNode, + Object FileDesc, + UInt32 FileAttributes, + UInt64 CreationTime, + UInt64 LastAccessTime, + UInt64 LastWriteTime, + UInt64 ChangeTime, + out FileInfo FileInfo) + { + FileInfo = default; + return STATUS_ACCESS_DENIED; + } + + public override Int32 SetFileSize( + Object FileNode, + Object FileDesc, + UInt64 NewSize, + Boolean SetAllocationSize, + out FileInfo FileInfo) + { + FileInfo = default; + return STATUS_ACCESS_DENIED; + } + + public override Int32 CanDelete( + Object FileNode, + Object FileDesc, + String FileName) + { + return STATUS_ACCESS_DENIED; + } + + public override Int32 Rename( + Object FileNode, + Object FileDesc, + String FileName, + String NewFileName, + Boolean ReplaceIfExists) + { + return STATUS_ACCESS_DENIED; + } + + public override Int32 GetSecurity( + Object FileNode, + Object FileDesc, + ref Byte[] SecurityDescriptor) + { + return STATUS_INVALID_DEVICE_REQUEST; + } + + public override Int32 SetSecurity( + Object FileNode, + Object FileDesc, + AccessControlSections Sections, + Byte[] SecurityDescriptor) + { + return STATUS_ACCESS_DENIED; + } + + // === Guarded async methods (test-facing API — no WinFsp native types) === + + public Task GuardedReadFileAsync(string path, byte[] buffer, long offset, CancellationToken ct = default) + => _vfs.ReadFileAsync(path, buffer, offset, ct); + + public Task> GuardedListDirectoryAsync(string path, CancellationToken ct = default) + => _vfs.ListDirectoryAsync(path, ct); + + public Task GuardedGetFileInfoAsync(string path, CancellationToken ct = default) + => _vfs.GetFileInfoAsync(path, ct); + + public Task GuardedFileExistsAsync(string path, CancellationToken ct = default) + => _vfs.FileExistsAsync(path, ct); + + public Task GuardedDirectoryExistsAsync(string path, CancellationToken ct = default) + => _vfs.DirectoryExistsAsync(path, ct); + + // === Helpers === + + private static FileInfo ToWinFspFileInfo(VfsFileInfo entry) + { + var fi = default(FileInfo); + fi.FileAttributes = (uint)(entry.Attributes | System.IO.FileAttributes.ReadOnly); + fi.FileSize = (ulong)entry.SizeBytes; + fi.AllocationSize = (ulong)((entry.SizeBytes + 4095) & ~4095L); // Round up to 4K + fi.CreationTime = (ulong)entry.CreationTimeUtc.ToFileTimeUtc(); + fi.LastAccessTime = (ulong)entry.LastAccessTimeUtc.ToFileTimeUtc(); + fi.LastWriteTime = (ulong)entry.LastWriteTimeUtc.ToFileTimeUtc(); + fi.ChangeTime = (ulong)entry.LastWriteTimeUtc.ToFileTimeUtc(); + return fi; + } +} diff --git a/src/ZipDrive.Infrastructure.FileSystem/DokanHostedService.cs b/src/ZipDrive.Infrastructure.FileSystem/WinFspHostedService.cs similarity index 86% rename from src/ZipDrive.Infrastructure.FileSystem/DokanHostedService.cs rename to src/ZipDrive.Infrastructure.FileSystem/WinFspHostedService.cs index ab81e9d..398e969 100644 --- a/src/ZipDrive.Infrastructure.FileSystem/DokanHostedService.cs +++ b/src/ZipDrive.Infrastructure.FileSystem/WinFspHostedService.cs @@ -1,5 +1,5 @@ using System.Runtime.Versioning; -using DokanNet; +using Fsp; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -11,36 +11,35 @@ namespace ZipDrive.Infrastructure.FileSystem; /// -/// Background service that manages the Dokan mount lifecycle and dynamic reload. +/// Background service that manages the WinFsp mount lifecycle and dynamic reload. /// Mounts VFS on start, watches for archive directory changes, and unmounts on Ctrl+C / host shutdown. /// [SupportedOSPlatform("windows")] -public sealed class DokanHostedService : BackgroundService +public sealed class WinFspHostedService : BackgroundService { private readonly IVirtualFileSystem _vfs; private readonly IArchiveManager _archiveManager; private readonly IArchiveDiscovery _discovery; - private readonly DokanFileSystemAdapter _adapter; + private readonly WinFspFileSystemAdapter _adapter; private readonly MountSettings _mountSettings; private readonly IHostApplicationLifetime _lifetime; private readonly IFormatRegistry _formatRegistry; - private readonly ILogger _logger; + private readonly ILogger _logger; - private Dokan? _dokan; - private DokanInstance? _dokanInstance; + private FileSystemHost? _host; private FileSystemWatcher? _watcher; private ArchiveChangeConsolidator? _consolidator; private CancellationToken _stoppingToken; - public DokanHostedService( + public WinFspHostedService( IVirtualFileSystem vfs, IArchiveManager archiveManager, IArchiveDiscovery discovery, - DokanFileSystemAdapter adapter, + WinFspFileSystemAdapter adapter, IOptions mountSettings, IFormatRegistry formatRegistry, IHostApplicationLifetime lifetime, - ILogger logger) + ILogger logger) { _vfs = vfs; _archiveManager = archiveManager; @@ -150,37 +149,43 @@ await _vfs.MountAsync(new VfsMountOptions return; } - // Create Dokan instance - _dokan = new Dokan(new DokanNetLogger(_logger)); + // Create WinFsp file system host + _host = new FileSystemHost(_adapter); + _host.FileSystemName = "NTFS"; - var dokanBuilder = new DokanInstanceBuilder(_dokan) - .ConfigureOptions(options => - { - options.Options = DokanOptions.WriteProtection | DokanOptions.FixedDrive | DokanOptions.MountManager; - options.MountPoint = _mountSettings.MountPoint; - }); - - _dokanInstance = dokanBuilder.Build(_adapter); + // Mount the file system + // WinFsp mount point format: drive letter with trailing backslash + string mountPoint = _mountSettings.MountPoint; + int result = _host.Mount(mountPoint); + if (result < 0) + { + _logger.LogError("WinFsp mount failed with NTSTATUS 0x{Status:X8}. Ensure WinFsp is installed from https://winfsp.dev/rel/", + unchecked((uint)result)); + _lifetime.StopApplication(); + return; + } - _logger.LogInformation("Drive mounted at {MountPoint}. Press Ctrl+C to unmount.", _mountSettings.MountPoint); + _logger.LogInformation("Drive mounted at {MountPoint}. Press Ctrl+C to unmount.", mountPoint); - // Step 4: Block until Dokan file system is closed - await _dokanInstance.WaitForFileSystemClosedAsync(uint.MaxValue); + // Block until cancellation + try + { + await Task.Delay(Timeout.Infinite, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Normal shutdown — fall through to StopAsync + } } catch (DllNotFoundException) { - _logger.LogError("Dokany driver is not installed. ZipDrive requires Dokany to mount virtual drives"); + _logger.LogError("WinFsp driver is not installed. ZipDrive requires WinFsp to mount virtual drives"); UserNotice.Error( - "Dokany driver is not installed.\n" + - "ZipDrive requires Dokany to mount virtual drives.\n" + - "Download and install from: https://github.com/dokan-dev/dokany/releases"); + "WinFsp driver is not installed.\n" + + "ZipDrive requires WinFsp to mount virtual drives.\n" + + "Download and install from: https://winfsp.dev/rel/"); WaitForKeyAndStop(); } - catch (DokanException ex) - { - _logger.LogError(ex, "Dokan mount failed. Ensure Dokany v2.3.1.1000 is installed from https://github.com/dokan-dev/dokany/releases/tag/v2.3.1.1000"); - _lifetime.StopApplication(); - } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { _logger.LogInformation("Mount cancelled"); @@ -201,10 +206,7 @@ public override async Task StopAsync(CancellationToken cancellationToken) try { - if (_dokan != null) - { - _dokan.RemoveMountPoint(_mountSettings.MountPoint); - } + _host?.Unmount(); } catch (Exception ex) { @@ -223,8 +225,7 @@ public override async Task StopAsync(CancellationToken cancellationToken) _logger.LogWarning(ex, "Error unmounting VFS"); } - _dokanInstance?.Dispose(); - _dokan?.Dispose(); + _host?.Dispose(); _logger.LogInformation("Drive unmounted cleanly"); @@ -543,22 +544,4 @@ private void WaitForKeyAndStop() _lifetime.StopApplication(); } - - /// - /// Adapter for DokanNet's ILogger to Microsoft.Extensions.Logging. - /// - private sealed class DokanNetLogger : DokanNet.Logging.ILogger - { - private readonly ILogger _logger; - - public DokanNetLogger(ILogger logger) => _logger = logger; - - public void Debug(string message, params object[] args) => _logger.LogDebug(message, args); - public void Info(string message, params object[] args) => _logger.LogInformation(message, args); - public void Warn(string message, params object[] args) => _logger.LogWarning(message, args); - public void Error(string message, params object[] args) => _logger.LogError(message, args); - public void Fatal(string message, params object[] args) => _logger.LogCritical(message, args); - - public bool DebugEnabled => _logger.IsEnabled(LogLevel.Debug); - } } diff --git a/src/ZipDrive.Infrastructure.FileSystem/ZipDrive.Infrastructure.FileSystem.csproj b/src/ZipDrive.Infrastructure.FileSystem/ZipDrive.Infrastructure.FileSystem.csproj index 5fac852..9a5883c 100644 --- a/src/ZipDrive.Infrastructure.FileSystem/ZipDrive.Infrastructure.FileSystem.csproj +++ b/src/ZipDrive.Infrastructure.FileSystem/ZipDrive.Infrastructure.FileSystem.csproj @@ -12,7 +12,7 @@ - + diff --git a/tests/ZipDrive.Domain.Tests/SingleFileMountIntegrationTests.cs b/tests/ZipDrive.Domain.Tests/SingleFileMountIntegrationTests.cs index bfa06f4..e2d4ac1 100644 --- a/tests/ZipDrive.Domain.Tests/SingleFileMountIntegrationTests.cs +++ b/tests/ZipDrive.Domain.Tests/SingleFileMountIntegrationTests.cs @@ -121,7 +121,7 @@ public async Task UnsupportedFile_ReturnsFalseAndDoesNotMount() var (vfs, _, formatRegistry) = CreateInfrastructure(); - // Simulate the DokanHostedService logic: check format + // Simulate the WinFspHostedService logic: check format string? formatId = formatRegistry.DetectFormat(txtPath); formatId.Should().BeNull("docx is not a supported format"); @@ -139,7 +139,7 @@ public void NonExistentPath_NeitherFileNorDirectory() File.Exists(fakePath).Should().BeFalse(); Directory.Exists(fakePath).Should().BeFalse(); - // DokanHostedService would show UserNotice.Error and WaitForKeyAndStop + // WinFspHostedService would show UserNotice.Error and WaitForKeyAndStop } // === 5.4 Empty directory mounts with zero archives === @@ -159,7 +159,7 @@ public async Task EmptyDirectory_MountsWithZeroArchives() // Cast to IArchiveManager to check registered archives IArchiveManager manager = vfs; manager.GetRegisteredArchives().Should().BeEmpty( - "DokanHostedService checks this and shows a WARNING notice"); + "WinFspHostedService checks this and shows a WARNING notice"); } // === 5.5 Directory with archives — existing behavior unchanged === @@ -203,7 +203,7 @@ public async Task SingleFileMode_MountsWithoutWatcherInfrastructure() result.Should().BeTrue(); vfs.IsMounted.Should().BeTrue(); - // No FileSystemWatcher is created — that's DokanHostedService's responsibility + // No FileSystemWatcher is created — that's WinFspHostedService's responsibility // and it only calls StartWatcher() in the directory branch. } } diff --git a/tests/ZipDrive.EnduranceTests/EnduranceTest.cs b/tests/ZipDrive.EnduranceTests/EnduranceTest.cs index 5ba94f7..56fbcf3 100644 --- a/tests/ZipDrive.EnduranceTests/EnduranceTest.cs +++ b/tests/ZipDrive.EnduranceTests/EnduranceTest.cs @@ -39,7 +39,7 @@ public class EnduranceTest : IAsyncLifetime private string _rootPath = ""; private ArchiveVirtualFileSystem _vfs = null!; - private DokanFileSystemAdapter _adapter = null!; + private WinFspFileSystemAdapter _adapter = null!; private FileContentCache _fileCache = null!; private IArchiveStructureCache _structureCache = null!; private double _durationHours; @@ -135,8 +135,8 @@ public async Task InitializeAsync() NullLogger.Instance); await _vfs.MountAsync(new VfsMountOptions { RootPath = _rootPath, MaxDiscoveryDepth = 6 }); - _adapter = new DokanFileSystemAdapter( - _vfs, Options.Create(new MountSettings()), NullLogger.Instance); + _adapter = new WinFspFileSystemAdapter( + _vfs, Options.Create(new MountSettings()), NullLogger.Instance); } public async Task DisposeAsync() diff --git a/tests/ZipDrive.EnduranceTests/Suites/ConcurrencyStressSuite.cs b/tests/ZipDrive.EnduranceTests/Suites/ConcurrencyStressSuite.cs index 0b61055..c2020c9 100644 --- a/tests/ZipDrive.EnduranceTests/Suites/ConcurrencyStressSuite.cs +++ b/tests/ZipDrive.EnduranceTests/Suites/ConcurrencyStressSuite.cs @@ -17,7 +17,7 @@ public sealed class ConcurrencyStressSuite : EnduranceSuiteBase public override int TaskCount => 20; public ConcurrencyStressSuite( - DokanFileSystemAdapter adapter, + WinFspFileSystemAdapter adapter, ConcurrentDictionary manifests, List archivePaths, FileContentCache fileCache, diff --git a/tests/ZipDrive.EnduranceTests/Suites/DynamicReloadSuite.cs b/tests/ZipDrive.EnduranceTests/Suites/DynamicReloadSuite.cs index d1e1d14..e917ca3 100644 --- a/tests/ZipDrive.EnduranceTests/Suites/DynamicReloadSuite.cs +++ b/tests/ZipDrive.EnduranceTests/Suites/DynamicReloadSuite.cs @@ -9,7 +9,7 @@ namespace ZipDrive.EnduranceTests.Suites; /// -/// Endurance suite testing dynamic archive add/remove through DokanFileSystemAdapter. +/// Endurance suite testing dynamic archive add/remove through WinFspFileSystemAdapter. /// All reads go through Adapter.Guarded*Async. Lifecycle ops go through IArchiveManager. /// Uses dedicated reload-only archives isolated from other suites. /// @@ -35,7 +35,7 @@ public sealed class DynamicReloadSuite : EnduranceSuiteBase public override int TaskCount => 20; public DynamicReloadSuite( - DokanFileSystemAdapter adapter, + WinFspFileSystemAdapter adapter, IArchiveManager archiveManager, ConcurrentDictionary manifests, List archivePaths, diff --git a/tests/ZipDrive.EnduranceTests/Suites/EdgeCaseSuite.cs b/tests/ZipDrive.EnduranceTests/Suites/EdgeCaseSuite.cs index 796a08e..9e80640 100644 --- a/tests/ZipDrive.EnduranceTests/Suites/EdgeCaseSuite.cs +++ b/tests/ZipDrive.EnduranceTests/Suites/EdgeCaseSuite.cs @@ -17,7 +17,7 @@ public sealed class EdgeCaseSuite : EnduranceSuiteBase public override int TaskCount => 10; public EdgeCaseSuite( - DokanFileSystemAdapter adapter, + WinFspFileSystemAdapter adapter, ConcurrentDictionary manifests, List archivePaths, FileContentCache fileCache, diff --git a/tests/ZipDrive.EnduranceTests/Suites/EnduranceSuiteBase.cs b/tests/ZipDrive.EnduranceTests/Suites/EnduranceSuiteBase.cs index 6c8003e..aa90db2 100644 --- a/tests/ZipDrive.EnduranceTests/Suites/EnduranceSuiteBase.cs +++ b/tests/ZipDrive.EnduranceTests/Suites/EnduranceSuiteBase.cs @@ -15,7 +15,7 @@ namespace ZipDrive.EnduranceTests.Suites; /// public abstract class EnduranceSuiteBase : IEnduranceSuite { - protected readonly DokanFileSystemAdapter Adapter; + protected readonly WinFspFileSystemAdapter Adapter; protected readonly ConcurrentDictionary Manifests; protected readonly List ArchivePaths; protected readonly FileContentCache FileCache; @@ -28,7 +28,7 @@ public abstract class EnduranceSuiteBase : IEnduranceSuite public abstract int TaskCount { get; } protected EnduranceSuiteBase( - DokanFileSystemAdapter adapter, + WinFspFileSystemAdapter adapter, ConcurrentDictionary manifests, List archivePaths, FileContentCache fileCache, diff --git a/tests/ZipDrive.EnduranceTests/Suites/EvictionValidationSuite.cs b/tests/ZipDrive.EnduranceTests/Suites/EvictionValidationSuite.cs index 4b26a12..1e59087 100644 --- a/tests/ZipDrive.EnduranceTests/Suites/EvictionValidationSuite.cs +++ b/tests/ZipDrive.EnduranceTests/Suites/EvictionValidationSuite.cs @@ -17,7 +17,7 @@ public sealed class EvictionValidationSuite : EnduranceSuiteBase public override int TaskCount => 10; public EvictionValidationSuite( - DokanFileSystemAdapter adapter, + WinFspFileSystemAdapter adapter, ConcurrentDictionary manifests, List archivePaths, FileContentCache fileCache, diff --git a/tests/ZipDrive.EnduranceTests/Suites/LatencyMeasurementSuite.cs b/tests/ZipDrive.EnduranceTests/Suites/LatencyMeasurementSuite.cs index c2b73d7..f4946ee 100644 --- a/tests/ZipDrive.EnduranceTests/Suites/LatencyMeasurementSuite.cs +++ b/tests/ZipDrive.EnduranceTests/Suites/LatencyMeasurementSuite.cs @@ -21,7 +21,7 @@ public sealed class LatencyMeasurementSuite : EnduranceSuiteBase public override int TaskCount => 5; public LatencyMeasurementSuite( - DokanFileSystemAdapter adapter, + WinFspFileSystemAdapter adapter, ConcurrentDictionary manifests, List archivePaths, FileContentCache fileCache, diff --git a/tests/ZipDrive.EnduranceTests/Suites/NormalReadSuite.cs b/tests/ZipDrive.EnduranceTests/Suites/NormalReadSuite.cs index fa8eda9..5e4e373 100644 --- a/tests/ZipDrive.EnduranceTests/Suites/NormalReadSuite.cs +++ b/tests/ZipDrive.EnduranceTests/Suites/NormalReadSuite.cs @@ -17,7 +17,7 @@ public sealed class NormalReadSuite : EnduranceSuiteBase public override int TaskCount => 25; public NormalReadSuite( - DokanFileSystemAdapter adapter, + WinFspFileSystemAdapter adapter, ConcurrentDictionary manifests, List archivePaths, FileContentCache fileCache, diff --git a/tests/ZipDrive.EnduranceTests/Suites/PartialReadSuite.cs b/tests/ZipDrive.EnduranceTests/Suites/PartialReadSuite.cs index a054490..4a860bd 100644 --- a/tests/ZipDrive.EnduranceTests/Suites/PartialReadSuite.cs +++ b/tests/ZipDrive.EnduranceTests/Suites/PartialReadSuite.cs @@ -17,7 +17,7 @@ public sealed class PartialReadSuite : EnduranceSuiteBase public override int TaskCount => 20; public PartialReadSuite( - DokanFileSystemAdapter adapter, + WinFspFileSystemAdapter adapter, ConcurrentDictionary manifests, List archivePaths, FileContentCache fileCache, diff --git a/tests/ZipDrive.EnduranceTests/Suites/PathResolutionSuite.cs b/tests/ZipDrive.EnduranceTests/Suites/PathResolutionSuite.cs index eb99bee..8611588 100644 --- a/tests/ZipDrive.EnduranceTests/Suites/PathResolutionSuite.cs +++ b/tests/ZipDrive.EnduranceTests/Suites/PathResolutionSuite.cs @@ -17,7 +17,7 @@ public sealed class PathResolutionSuite : EnduranceSuiteBase public override int TaskCount => 8; public PathResolutionSuite( - DokanFileSystemAdapter adapter, + WinFspFileSystemAdapter adapter, ConcurrentDictionary manifests, List archivePaths, FileContentCache fileCache,