diff --git a/Directory.Packages.props b/Directory.Packages.props
index a7fb7e8..00e66b6 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,7 +12,7 @@
-
+
@@ -22,5 +22,7 @@
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index ce67881..2c5d8aa 100644
--- a/README.md
+++ b/README.md
@@ -138,13 +138,13 @@ Measured with [BenchmarkDotNet](https://benchmarkdotnet.org/) (Intel Core i9-139
| Scenario | RAM Disk | NVMe SSD | Ratio |
|---|---:|---:|---:|
-| Sequential write, 4 KB | 2.2 MB/s | 0.8 MB/s | **2.9× faster** |
-| Sequential write, 1 MB | 580 MB/s | 104 MB/s | **5.6× faster** |
-| Sequential read, 4 KB / 1 MB | 3.0 MB/s / 643 MB/s | 3.0 MB/s / 677 MB/s | ≈ parity |
-| Random 4 KB read, 30 seeks over 16 MB | 1.9–2.3 ms | 1.6 ms | ~1.2–1.4× slower |
-| 30× small-file (4 KB) create+write | 51.6 ms (1.72 ms/file) | 80.7 ms (2.69 ms/file) | **1.6× faster** |
+| Sequential write, 4 KB | 1.31 MB/s | 0.87 MB/s | **1.5× faster** |
+| Sequential write, 1 MB | 321.9 MB/s | 84.6 MB/s | **3.8× faster** |
+| Sequential read (OS cache), 4 KB / 1 MB | 3.4 MB/s / 531.6 MB/s | 4.5 MB/s / 955.5 MB/s | ~1.3–1.8× slower |
+| Random 4 KB read, 30 seeks over 16 MB | 1.7× slower (OS cache) – 1.6× faster (uncached) | — | mixed |
+| 30× small-file (4 KB) create+write | 84.1 ms (2.81 ms/file) | 144.8 ms (4.83 ms/file) | **1.7× faster** |
-Writes and small-file create+write win big by skipping block allocation, journaling, and the physical write; sequential reads land near parity with an OS page-cache hit (a user-mode file system can't consistently beat the kernel's own DRAM cache); random reads are modestly slower since each seek pays a kernel–userspace round-trip through WinFsp. Run `dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` for current numbers on your own hardware (see [Running Benchmarks](#running-benchmarks) below).
+Writes and small-file create+write win big by skipping block allocation, journaling, and the physical write; sequential reads trail the NVMe drive even on an OS page-cache hit (a user-mode file system can't consistently beat the kernel's own DRAM cache, and every WinFsp callback pays a kernel–userspace round trip); random reads land close to parity, faster or slower depending on whether the physical disk's own cache is warm. Run `dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` for current numbers on your own hardware (see [Running Benchmarks](#running-benchmarks) below).
### Running Tests
@@ -169,7 +169,7 @@ BenchmarkDotNet will prompt you to pick which benchmark class(es) to run (`Seque
`mdrive.exe` ships alongside `ManagedDrive.exe` and forwards commands to the running app over a named pipe, so scripts can drive ManagedDrive without opening the UI. If the app isn't already running, `mdrive` launches it and retries for up to 10 seconds before giving up.
```powershell
-mdrive mount C:\disks\scratch.mdr R: --auto-mount --compression Optimal
+mdrive mount C:\disks\scratch.mdr R: --auto-mount --compression Optimal --custom-zstd-level 19
mdrive list
mdrive save R:
mdrive format R: --yes
@@ -179,7 +179,7 @@ mdrive exit
| Command | Description |
|---|---|
-| `mount [options]` | Mounts an existing `.mdr` image at a drive letter. Options: `--read-only`, `--auto-mount`, `--auto-save-minutes`, `--compression `, `--max-snapshot-count`, `--max-snapshot-size-mb`, `--high-usage-warn-percent`, `--password`, `--password-file` (mutually exclusive; needed only if the image is encrypted — `--password-file` reads the first line of a file and is recommended over `--password` to avoid exposing it in shell history or the process list). Any option left unset keeps the image's saved profile value (or its default). |
+| `mount [options]` | Mounts an existing `.mdr` image at a drive letter. Options: `--read-only`, `--auto-mount`, `--auto-save-minutes`, `--compression `, `--custom-zstd-level <1-22>` (overrides the preset Zstd level mapped from `--compression`; only takes effect when the compression level is not `None`), `--max-snapshot-count`, `--max-snapshot-size-mb`, `--high-usage-warn-percent`, `--password`, `--password-file` (mutually exclusive; needed only if the image is encrypted — `--password-file` reads the first line of a file and is recommended over `--password` to avoid exposing it in shell history or the process list). Any option left unset keeps the image's saved profile value (or its default). |
| `mount-archive [drive-letter]` | Imports an archive (zip/7z/rar/tar/...) as a read-only disk and opens it in Explorer once mounted. `drive-letter` is optional — if omitted, the first free letter from `Z:` down to `D:` is used. Used internally by the Explorer right-click menu entry. |
| `unmount ` | Unmounts a mounted disk. |
| `format --yes` | Deletes all files on a mounted disk. Requires `--yes`/`-y` to confirm. |
@@ -365,13 +365,13 @@ ManagedDrive 使用 **WinFsp**(Windows 文件系统代理)将内存目录树
| 场景 | 内存盘 | NVMe SSD | 倍率 |
|---|---:|---:|---:|
-| 顺序写入,4 KB | 2.2 MB/s | 0.8 MB/s | **快 2.9×** |
-| 顺序写入,1 MB | 580 MB/s | 104 MB/s | **快 5.6×** |
-| 顺序读取,4 KB / 1 MB | 3.0 MB/s / 643 MB/s | 3.0 MB/s / 677 MB/s | ≈ 持平 |
-| 随机 4 KB 读取,对 16 MB 文件寻址 30 次 | 1.9–2.3 ms | 1.6 ms | 慢 ~1.2–1.4× |
-| 30 次小文件(4 KB)创建+写入 | 51.6 ms(1.72 ms/文件) | 80.7 ms(2.69 ms/文件) | **快 1.6×** |
+| 顺序写入,4 KB | 1.31 MB/s | 0.87 MB/s | **快 1.5×** |
+| 顺序写入,1 MB | 321.9 MB/s | 84.6 MB/s | **快 3.8×** |
+| 顺序读取(OS 缓存),4 KB / 1 MB | 3.4 MB/s / 531.6 MB/s | 4.5 MB/s / 955.5 MB/s | 慢 ~1.3–1.8× |
+| 随机 4 KB 读取,对 16 MB 文件寻址 30 次 | 慢 1.7×(OS 缓存)~快 1.6×(未缓存) | — | 不一致 |
+| 30 次小文件(4 KB)创建+写入 | 84.1 ms(2.81 ms/文件) | 144.8 ms(4.83 ms/文件) | **快 1.7×** |
-写入及小文件创建+写入优势明显,因为跳过了物理块分配、日志记录和实际落盘;顺序读取与 OS 页缓存命中基本持平(用户态文件系统无法稳定超越内核自身的 DRAM 缓存);随机读取略慢,因为每次寻址都要经过 WinFsp 的内核–用户态往返。运行 `dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` 可在你自己的硬件上获取当前数据(见下方[运行基准测试](#running-benchmarks-zh))。
+写入及小文件创建+写入优势明显,因为跳过了物理块分配、日志记录和实际落盘;顺序读取即便命中 OS 页缓存也不及 NVMe 硬盘(用户态文件系统无法稳定超越内核自身的 DRAM 缓存,且每次 WinFsp 回调都要经过一次内核–用户态往返);随机读取基本持平,具体快慢取决于物理磁盘自身缓存是否命中。运行 `dotnet run --project benchmarks/ManagedDrive.Benchmarks -c Release` 可在你自己的硬件上获取当前数据(见下方[运行基准测试](#running-benchmarks-zh))。
### 运行测试
@@ -398,7 +398,7 @@ BenchmarkDotNet 会提示你选择要运行的基准测试类(`SequentialReadW
`mdrive.exe` 随 `ManagedDrive.exe` 一同发布,通过命名管道将命令转发给正在运行的应用,因此脚本无需打开界面即可操作 ManagedDrive。若应用尚未运行,`mdrive` 会自动启动它,并在最长 10 秒内重试。
```powershell
-mdrive mount C:\disks\scratch.mdr R: --auto-mount --compression Optimal
+mdrive mount C:\disks\scratch.mdr R: --auto-mount --compression Optimal --custom-zstd-level 19
mdrive list
mdrive save R:
mdrive format R: --yes
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index ee79185..a9c4815 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -11,6 +11,48 @@ ManagedDrive uses the following open-source software.
## SharpCompress
-- **Package:** `SharpCompress` 0.49.1
+- **Package:** `SharpCompress` 0.50.3
- **Copyright:** Copyright (c) 2025 Adam Hathcock
- **License:** [MIT License](https://github.com/adamhathcock/sharpcompress/blob/master/LICENSE)
+
+## ZstdSharp.Port
+
+- **Package:** `ZstdSharp.Port` 0.8.8
+- **Copyright:** Copyright (c) Oleg Stepanischev
+- **License:** [MIT License](https://github.com/oleg-st/ZstdSharp/blob/master/LICENSE)
+
+## ThrottledLogging
+
+- **Package:** `ThrottledLogging` 1.0.10
+- **Copyright:** Copyright (c) coldhighsun
+- **License:** [MIT License](https://github.com/coldhighsun/ThrottledLogging/blob/master/LICENSE)
+
+## System.CommandLine
+
+- **Package:** `System.CommandLine` 2.0.10
+- **Copyright:** © Microsoft Corporation
+- **License:** [MIT License](https://github.com/dotnet/command-line-api/blob/main/LICENSE.md)
+
+## Spectre.Console
+
+- **Package:** `Spectre.Console` 0.57.2
+- **Copyright:** Copyright (c) Patrik Svensson, Phil Scott, Nils Andresen, Cédric Luthi
+- **License:** [MIT License](https://github.com/spectreconsole/spectre.console/blob/main/LICENSE.md)
+
+## YamlDotNet
+
+- **Package:** `YamlDotNet` 18.1.0
+- **Copyright:** Copyright (c) Antoine Aubry and contributors
+- **License:** [MIT License](https://github.com/aaubry/YamlDotNet/blob/master/LICENSE.txt)
+
+## Microsoft.Extensions.* (Hosting.WindowsServices, Logging, Logging.Abstractions, DependencyInjection)
+
+- **Packages:** `Microsoft.Extensions.Hosting.WindowsServices`, `Microsoft.Extensions.Logging`, `Microsoft.Extensions.Logging.Abstractions`, `Microsoft.Extensions.DependencyInjection` 10.0.10
+- **Copyright:** © Microsoft Corporation
+- **License:** [MIT License](https://github.com/dotnet/runtime/blob/main/LICENSE.TXT)
+
+## Serilog and Serilog sinks (Serilog, Serilog.Extensions.Logging, Serilog.Sinks.File, Serilog.Sinks.Async)
+
+- **Packages:** `Serilog` 4.4.0, `Serilog.Extensions.Logging` 10.0.0, `Serilog.Sinks.File` 7.0.0, `Serilog.Sinks.Async` 2.1.0
+- **Copyright:** Copyright © Serilog Contributors
+- **License:** [Apache License 2.0](https://github.com/serilog/serilog/blob/dev/LICENSE)
diff --git a/src/ManagedDrive.App/App.xaml.cs b/src/ManagedDrive.App/App.xaml.cs
index 20c3c92..be9e2da 100644
--- a/src/ManagedDrive.App/App.xaml.cs
+++ b/src/ManagedDrive.App/App.xaml.cs
@@ -59,19 +59,7 @@ private void App_Exit(object sender, ExitEventArgs e)
{
_logger.LogInformation("App_Exit invoked.");
- if (_sessionEndingSaveHandler != null)
- {
- SystemEvents.SessionEnding -= _sessionEndingSaveHandler.OnSessionEnding;
- }
- if (_mountManager != null && _trayIconController != null)
- {
- _mountManager.ActivityDetected -= _trayIconController.OnActivityDetected;
- }
- _mainViewModel?.SaveSettings();
- _trayIconController?.Dispose();
- _mainViewModel?.Dispose();
-
- _cliPipeServer?.Dispose();
+ TeardownBeforeMountManagerDispose();
// Safety net: if ShutdownAsync already disposed the mount manager, this is a no-op.
// Bounded so a stuck final save can't hang process exit indefinitely.
@@ -153,7 +141,9 @@ private async void App_Startup(object sender, StartupEventArgs e)
_trayTooltipController = new(_mainViewModel, _trayIconController);
_tempDirCompatChecker = new(_settings, _trayIconController, () => _mainWindow is { IsLoaded: true } ? _mainWindow : null);
_mountManager.ActivityDetected += _trayIconController.OnActivityDetected;
- _diskNotificationService = new(_mainViewModel, _trayIconController, () => _mainWindow!.IsVisible);
+ _diskNotificationService = new(
+ _mainViewModel, _trayIconController, () => _mainWindow!.IsVisible,
+ _serviceProvider!.GetRequiredService>());
// Constructed before AutoMountDisksAsync so that an auto-mounted disk which is already the
// TEMP target gets its global symlink published at startup. Rooted as a field only to keep
@@ -438,10 +428,6 @@ private async Task ShutdownAsync()
{
_logger.LogInformation("ShutdownAsync starting.");
- if (_sessionEndingSaveHandler != null)
- {
- SystemEvents.SessionEnding -= _sessionEndingSaveHandler.OnSessionEnding;
- }
_isExiting = true;
if (_mainViewModel != null)
@@ -450,15 +436,7 @@ private async Task ShutdownAsync()
ShowMainWindow();
}
- _cliPipeServer?.Dispose();
- _mainViewModel?.SaveSettings();
- _trayIconController?.Dispose();
- _mainViewModel?.Dispose();
-
- if (_mountManager != null && _trayIconController != null)
- {
- _mountManager.ActivityDetected -= _trayIconController.OnActivityDetected;
- }
+ TeardownBeforeMountManagerDispose();
await Task.Run(() => _mountManager?.Dispose((disk, diskFraction, overallFraction, totalBytes) =>
Application.Current.Dispatcher.BeginInvoke(() =>
@@ -467,4 +445,25 @@ await Task.Run(() => _mountManager?.Dispose((disk, diskFraction, overallFraction
_logger.LogInformation("ShutdownAsync completed; shutting down application.");
Shutdown();
}
+
+ ///
+ /// Common teardown shared by and , run
+ /// before either one disposes (which they do differently: a
+ /// bounded-wait safety net vs. an awaited call with exit-save progress reporting).
+ ///
+ private void TeardownBeforeMountManagerDispose()
+ {
+ if (_sessionEndingSaveHandler != null)
+ {
+ SystemEvents.SessionEnding -= _sessionEndingSaveHandler.OnSessionEnding;
+ }
+ if (_mountManager != null && _trayIconController != null)
+ {
+ _mountManager.ActivityDetected -= _trayIconController.OnActivityDetected;
+ }
+ _cliPipeServer?.Dispose();
+ _mainViewModel?.SaveSettings();
+ _trayIconController?.Dispose();
+ _mainViewModel?.Dispose();
+ }
}
\ No newline at end of file
diff --git a/src/ManagedDrive.App/Cli/MainViewModelCliDiskController.cs b/src/ManagedDrive.App/Cli/MainViewModelCliDiskController.cs
index 8ca83e4..5ed30ed 100644
--- a/src/ManagedDrive.App/Cli/MainViewModelCliDiskController.cs
+++ b/src/ManagedDrive.App/Cli/MainViewModelCliDiskController.cs
@@ -7,6 +7,14 @@ namespace ManagedDrive.App.Cli;
/// ManagedDrive.Cli.Core project (which cannot reference the WPF app layer without creating
/// a circular project reference) can drive disk mount/unmount/list operations.
///
+///
+/// This class is the entire contract surface between the CLI pipe server and :
+/// , ,
+/// , ,
+/// , and
+/// (plus the read-only collection for ). Changing
+/// any of their signatures requires updating this adapter in lockstep.
+///
internal sealed class MainViewModelCliDiskController(MainViewModel mainViewModel) : ICliDiskController
{
///
diff --git a/src/ManagedDrive.App/Infrastructure/WindowMaximizeHelper.cs b/src/ManagedDrive.App/Infrastructure/WindowMaximizeHelper.cs
index d0dde81..fe02d5c 100644
--- a/src/ManagedDrive.App/Infrastructure/WindowMaximizeHelper.cs
+++ b/src/ManagedDrive.App/Infrastructure/WindowMaximizeHelper.cs
@@ -18,10 +18,10 @@ public static void HookMaximizeBehavior(Window window)
window.SourceInitialized += (_, _) =>
{
var handle = new WindowInteropHelper(window).Handle;
- if (HwndSource.FromHwnd(handle) is HwndSource source)
+ if (HwndSource.FromHwnd(handle) is { } source)
{
- source.AddHook((IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) =>
- WndProc(hwnd, msg, wParam, lParam, ref handled, window));
+ source.AddHook((hwnd, msg, _, lParam, ref handled) =>
+ WndProc(hwnd, msg, lParam, ref handled, window));
}
};
@@ -65,7 +65,7 @@ private static void ClampMaximizedToWorkArea(Window window)
true);
}
- private static IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled, Window window)
+ private static IntPtr WndProc(IntPtr hwnd, int msg, IntPtr lParam, ref bool handled, Window window)
{
if (msg == WM_GETMINMAXINFO)
{
diff --git a/src/ManagedDrive.App/Localization/Strings.en-US.xaml b/src/ManagedDrive.App/Localization/Strings.en-US.xaml
index 62083a2..429fb69 100644
--- a/src/ManagedDrive.App/Localization/Strings.en-US.xaml
+++ b/src/ManagedDrive.App/Localization/Strings.en-US.xaml
@@ -73,7 +73,9 @@
Fast
Balanced
Max
- Balanced/Max compression may significantly increase save times. Choose carefully.
+ Custom Zstd level:
+ Advanced: override the compression preset above with an exact Zstd level (1 = fastest, 22 = smallest). Leave unchecked to use the preset's level.
+ Enter a custom Zstd level between 1 and 22.
Save image on exit
Auto-save the image, interval (minutes):
Interval (min)
@@ -315,10 +317,7 @@
An in-memory RAM disk for Windows.
© 2026 coldhighsun
View on GitHub
- Powered by WinFsp
- © 2015-2026 Bill Zissimopoulos — GNU GPLv2 (FUSE exception) or commercial license
- Powered by SharpCompress
- © 2025 Adam Hathcock — MIT License
+ Third-Party Notices
A new version ({0}) is available. Click to download.
diff --git a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml
index 0f9166c..3f0841a 100644
--- a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml
+++ b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml
@@ -73,7 +73,9 @@
快速
均衡
最高
- 均衡/最高压缩可能会显著增加保存时间,请谨慎选择。
+ 自定义 Zstd 级别:
+ 高级选项:用精确的 Zstd 级别覆盖上方的预设档位(1=最快,22=最小)。不勾选则使用预设档位对应的级别。
+ 请输入 1 到 22 之间的自定义 Zstd 级别。
退出时保存镜像
自动保存镜像,间隔(分钟):
间隔(分钟)
@@ -315,10 +317,7 @@
Windows 内存虚拟磁盘工具。
© 2026 coldhighsun
在 GitHub 上查看
- 基于 WinFsp 构建
- © 2015-2026 Bill Zissimopoulos — GNU GPLv2(附 FUSE 例外)或商业许可
- 基于 SharpCompress 构建
- © 2025 Adam Hathcock — MIT 许可证
+ 第三方声明
发现新版本 {0},点击下载。
diff --git a/src/ManagedDrive.App/ManagedDrive.App.csproj b/src/ManagedDrive.App/ManagedDrive.App.csproj
index 5df0ddb..fcf7bb4 100644
--- a/src/ManagedDrive.App/ManagedDrive.App.csproj
+++ b/src/ManagedDrive.App/ManagedDrive.App.csproj
@@ -33,6 +33,12 @@
+
+
+
+
+
diff --git a/src/ManagedDrive.App/Models/DiskProfile.cs b/src/ManagedDrive.App/Models/DiskProfile.cs
index e268ecf..87c26a1 100644
--- a/src/ManagedDrive.App/Models/DiskProfile.cs
+++ b/src/ManagedDrive.App/Models/DiskProfile.cs
@@ -70,6 +70,15 @@ public uint? AutoSaveIntervalMinutes
///
public ImageCompressionLevel CompressionLevel { get; init; } = ImageCompressionLevel.Fastest;
+ ///
+ /// Gets or sets the optional advanced override (1-22) of the exact Zstd level used instead of
+ /// the preset mapped from . null means use the preset.
+ ///
+ public int? CustomZstdLevel
+ {
+ get; init;
+ }
+
///
/// Gets or sets the optional maximum number of retained snapshot images. null
/// disables count-based snapshot pruning.
diff --git a/src/ManagedDrive.App/Services/DiskNotificationService.cs b/src/ManagedDrive.App/Services/DiskNotificationService.cs
index d4b194e..f4e305b 100644
--- a/src/ManagedDrive.App/Services/DiskNotificationService.cs
+++ b/src/ManagedDrive.App/Services/DiskNotificationService.cs
@@ -1,3 +1,5 @@
+using ThrottledLogging;
+
namespace ManagedDrive.App.Services;
///
@@ -9,6 +11,7 @@ public sealed class DiskNotificationService
{
private readonly HashSet _highUsageDisks = [];
private readonly Func _isMainWindowVisible;
+ private readonly ILogger _logger;
private readonly MainViewModel _mainViewModel;
private readonly TrayIconController _trayIconController;
@@ -19,11 +22,17 @@ public sealed class DiskNotificationService
/// The view model owning the disk collection.
/// Used to show balloon tips for warnings/failures.
/// Queried to set initial activity tracking on newly added disks.
- public DiskNotificationService(MainViewModel mainViewModel, TrayIconController trayIconController, Func isMainWindowVisible)
+ /// Used to record throttled high-usage warnings.
+ public DiskNotificationService(
+ MainViewModel mainViewModel,
+ TrayIconController trayIconController,
+ Func isMainWindowVisible,
+ ILogger logger)
{
_mainViewModel = mainViewModel;
_trayIconController = trayIconController;
_isMainWindowVisible = isMainWindowVisible;
+ _logger = logger;
_mainViewModel.Disks.CollectionChanged += (_, e) =>
{
@@ -90,6 +99,23 @@ private void OnDiskCapacityAdjusted(DiskViewModel vm)
_mainViewModel.StatusText = Loc.Format("Status.CapacityAdjusted", vm.MountPoint, originalMb, newMb);
}
+ private void OnDiskHighUsageWarning(object? sender, EventArgs e)
+ {
+ if (sender is not DiskViewModel vm)
+ {
+ return;
+ }
+
+ var title = Loc.Get("Tray.HighUsageTitle");
+ var body = Loc.Format("Tray.HighUsageBody", vm.VolumeLabel, vm.MountPoint, vm.UsedPercent);
+ _trayIconController.ShowBalloonTip(title, body, System.Windows.Forms.ToolTipIcon.Warning);
+
+ _logger.LogWarningThrottled(
+ $"high-usage:{vm.MountPoint}", TimeSpan.FromMinutes(10),
+ "Disk {MountPoint} ({VolumeLabel}) usage reached {UsedPercent:F1}%.",
+ vm.MountPoint, vm.VolumeLabel, vm.UsedPercent);
+ }
+
///
/// Tracks every disk currently in the state (which
/// fires on both the rising and falling edge, unlike the one-shot
@@ -116,18 +142,6 @@ private void OnDiskPropertyChanged(object? sender, PropertyChangedEventArgs e)
_trayIconController.SetHighUsageWarningActive(_highUsageDisks.Count > 0);
}
- private void OnDiskHighUsageWarning(object? sender, EventArgs e)
- {
- if (sender is not DiskViewModel vm)
- {
- return;
- }
-
- var title = Loc.Get("Tray.HighUsageTitle");
- var body = Loc.Format("Tray.HighUsageBody", vm.VolumeLabel, vm.MountPoint, vm.UsedPercent);
- _trayIconController.ShowBalloonTip(title, body, System.Windows.Forms.ToolTipIcon.Warning);
- }
-
private void OnDiskSaveFailed(object? sender, Exception ex)
{
if (sender is not DiskViewModel vm)
diff --git a/src/ManagedDrive.App/ViewModels/BusyOverlayViewModel.cs b/src/ManagedDrive.App/ViewModels/BusyOverlayViewModel.cs
index d6b71c7..e62814e 100644
--- a/src/ManagedDrive.App/ViewModels/BusyOverlayViewModel.cs
+++ b/src/ManagedDrive.App/ViewModels/BusyOverlayViewModel.cs
@@ -58,7 +58,11 @@ public double Progress
get;
private set
{
- if (Math.Abs(field - value) < 0.0001)
+ // The epsilon check below only suppresses redundant PropertyChanged notifications for
+ // near-identical intermediate ticks; it must never suppress storing the terminal value
+ // itself, or a final Report(1.0) that lands within epsilon of the last-stored value
+ // would leave `field` stuck just short of 1.0 forever (the bar visibly stops early).
+ if (value < 1.0 && Math.Abs(field - value) < 0.0001)
{
return;
}
diff --git a/src/ManagedDrive.App/ViewModels/DiskViewModel.cs b/src/ManagedDrive.App/ViewModels/DiskViewModel.cs
index 1fa981e..9a94354 100644
--- a/src/ManagedDrive.App/ViewModels/DiskViewModel.cs
+++ b/src/ManagedDrive.App/ViewModels/DiskViewModel.cs
@@ -15,13 +15,6 @@ public sealed class DiskViewModel : INotifyPropertyChanged, IDisposable
///
private const double HighUsageResetGap = 5.0;
- ///
- /// Throttle window for : the first access in a burst is
- /// reported immediately, subsequent accesses within this window are coalesced into a single
- /// trailing report when it elapses.
- ///
- private static readonly TimeSpan ActivityThrottleWindow = TimeSpan.FromMilliseconds(300);
-
///
/// Number of speed samples retained for the read/write history popup: 30 minutes at the
/// 2-second cadence. Sized generously since the buffer is only
@@ -30,10 +23,17 @@ public sealed class DiskViewModel : INotifyPropertyChanged, IDisposable
///
private const int SpeedHistoryLength = 900;
+ ///
+ /// Throttle window for : the first access in a burst is
+ /// reported immediately, subsequent accesses within this window are coalesced into a single
+ /// trailing report when it elapses.
+ ///
+ private static readonly TimeSpan ActivityThrottleWindow = TimeSpan.FromMilliseconds(300);
+
private readonly DispatcherTimer _activityThrottleTimer;
- private readonly DispatcherTimer _refreshTimer;
private readonly double[] _readSpeedHistory = new double[SpeedHistoryLength];
private readonly ThroughputTracker _readThroughput = new();
+ private readonly DispatcherTimer _refreshTimer;
private readonly double[] _writeSpeedHistory = new double[SpeedHistoryLength];
private readonly ThroughputTracker _writeThroughput = new();
@@ -271,6 +271,18 @@ public RelayCommand OpenInExplorerCommand
///
public string? PersistImagePath => Disk.Options.PersistImagePath;
+ ///
+ /// Gets the most recently sampled read throughput, formatted as e.g. "1.2 MB/s".
+ ///
+ public string ReadSpeedFormatted => ByteFormatter.FormatRate(_readBytesPerSecond);
+
+ ///
+ /// Gets the last 30 minutes of sampled read-speed history (bytes/sec), oldest first, for
+ /// display in the hover-triggered history chart. Only meaningfully consumed while that
+ /// popup is open; sampling itself runs unconditionally in .
+ ///
+ public IReadOnlyList ReadSpeedHistory => SnapshotHistory(_readSpeedHistory);
+
///
/// Gets whether this disk has auto-save enabled, controlling visibility of the
/// last-image-save timestamp on the disk card.
@@ -290,29 +302,6 @@ public RelayCommand OpenInExplorerCommand
///
public string? SourcePath => Disk.Options.SourceArchivePath ?? Disk.Options.PersistImagePath;
- ///
- /// Gets the most recently sampled read throughput, formatted as e.g. "1.2 MB/s".
- ///
- public string ReadSpeedFormatted => ByteFormatter.FormatRate(_readBytesPerSecond);
-
- ///
- /// Gets the last 30 minutes of sampled read-speed history (bytes/sec), oldest first, for
- /// display in the hover-triggered history chart. Only meaningfully consumed while that
- /// popup is open; sampling itself runs unconditionally in .
- ///
- public IReadOnlyList ReadSpeedHistory => SnapshotHistory(_readSpeedHistory);
-
- ///
- /// Gets the most recently sampled write throughput, formatted as e.g. "1.2 MB/s".
- ///
- public string WriteSpeedFormatted => ByteFormatter.FormatRate(_writeBytesPerSecond);
-
- ///
- /// Gets the last 30 minutes of sampled write-speed history (bytes/sec), oldest first. See
- /// .
- ///
- public IReadOnlyList WriteSpeedHistory => SnapshotHistory(_writeSpeedHistory);
-
///
/// Gets the amount of used space formatted as a human-readable string.
///
@@ -331,6 +320,17 @@ public RelayCommand OpenInExplorerCommand
///
public string VolumeLabel => Disk.Options.VolumeLabel;
+ ///
+ /// Gets the most recently sampled write throughput, formatted as e.g. "1.2 MB/s".
+ ///
+ public string WriteSpeedFormatted => ByteFormatter.FormatRate(_writeBytesPerSecond);
+
+ ///
+ /// Gets the last 30 minutes of sampled write-speed history (bytes/sec), oldest first. See
+ /// .
+ ///
+ public IReadOnlyList WriteSpeedHistory => SnapshotHistory(_writeSpeedHistory);
+
///
public void Dispose()
{
@@ -344,18 +344,6 @@ public void Dispose()
_writeThroughput.Reset();
}
- ///
- /// Reorders a fixed-length ring buffer written via into
- /// oldest-first order for display.
- ///
- private double[] SnapshotHistory(double[] buffer)
- {
- var result = new double[buffer.Length];
- Array.Copy(buffer, _speedHistoryHead, result, 0, buffer.Length - _speedHistoryHead);
- Array.Copy(buffer, 0, result, buffer.Length - _speedHistoryHead, _speedHistoryHead);
- return result;
- }
-
///
/// Refreshes usage statistics, volume label, and capacity immediately. Always recomputes
/// usage and re-evaluates the high-usage warning (it drives a tray balloon, so it must keep
@@ -539,6 +527,18 @@ private void ReportActivity(bool isWrite)
}
}
+ ///
+ /// Reorders a fixed-length ring buffer written via into
+ /// oldest-first order for display.
+ ///
+ private double[] SnapshotHistory(double[] buffer)
+ {
+ var result = new double[buffer.Length];
+ Array.Copy(buffer, _speedHistoryHead, result, 0, buffer.Length - _speedHistoryHead);
+ Array.Copy(buffer, 0, result, buffer.Length - _speedHistoryHead, _speedHistoryHead);
+ return result;
+ }
+
///
/// Event data for .
///
diff --git a/src/ManagedDrive.App/ViewModels/MainViewModel.cs b/src/ManagedDrive.App/ViewModels/MainViewModel.cs
index bbcaa8e..75c1554 100644
--- a/src/ManagedDrive.App/ViewModels/MainViewModel.cs
+++ b/src/ManagedDrive.App/ViewModels/MainViewModel.cs
@@ -1,5 +1,6 @@
using ManagedDrive.Cli.Core;
using System.Collections.ObjectModel;
+using static ManagedDrive.App.ViewModels.MainViewModelHelpers;
namespace ManagedDrive.App.ViewModels;
@@ -402,7 +403,7 @@ public void ExitWithoutConfirmation()
{
_logger.LogInformation("Exit requested via CLI.");
- if (IsTempOnAnyRamDisk())
+ if (TempDirCompatChecker.IsTempOnAnyDisk(Disks))
{
TempDirResetService.Reset();
}
@@ -447,25 +448,32 @@ public void ExitWithoutConfirmation()
///
/// A sequence of representing all mounted disks.
///
- public IEnumerable GetProfiles()
+ public IEnumerable GetProfiles() => Disks.Select(vm => ToProfile(vm.Disk.Options));
+
+ ///
+ /// Maps a live disk's to its persistable
+ /// counterpart. Inverse of ; kept as a standalone pure function
+ /// (rather than inlined in ) so both directions of this hand-written
+ /// field mapping can be round-trip tested independently of any live
+ /// or mounted disk.
+ ///
+ internal static DiskProfile ToProfile(DiskOptions options) => new()
{
- return Disks.Select(vm => new DiskProfile
- {
- MountPoint = vm.Disk.Options.MountPoint,
- VolumeLabel = vm.Disk.Options.VolumeLabel,
- CapacityBytes = vm.Disk.Options.CapacityBytes,
- ReadOnly = vm.Disk.Options.ReadOnly,
- AutoMount = vm.Disk.Options.AutoMount,
- PersistImagePath = vm.Disk.Options.PersistImagePath,
- SourceArchivePath = vm.Disk.Options.SourceArchivePath,
- AutoSaveIntervalMinutes = vm.Disk.Options.AutoSaveIntervalMinutes,
- CompressionLevel = vm.Disk.Options.CompressionLevel,
- MaxSnapshotCount = vm.Disk.Options.MaxSnapshotCount,
- MaxSnapshotSizeBytes = vm.Disk.Options.MaxSnapshotSizeBytes,
- HighUsageWarnPercent = vm.Disk.Options.HighUsageWarnPercent,
- SaveImageOnExit = vm.Disk.Options.SaveImageOnExit,
- });
- }
+ MountPoint = options.MountPoint,
+ VolumeLabel = options.VolumeLabel,
+ CapacityBytes = options.CapacityBytes,
+ ReadOnly = options.ReadOnly,
+ AutoMount = options.AutoMount,
+ PersistImagePath = options.PersistImagePath,
+ SourceArchivePath = options.SourceArchivePath,
+ AutoSaveIntervalMinutes = options.AutoSaveIntervalMinutes,
+ CompressionLevel = options.CompressionLevel,
+ CustomZstdLevel = options.CustomZstdLevel,
+ MaxSnapshotCount = options.MaxSnapshotCount,
+ MaxSnapshotSizeBytes = options.MaxSnapshotSizeBytes,
+ HighUsageWarnPercent = options.HighUsageWarnPercent,
+ SaveImageOnExit = options.SaveImageOnExit,
+ };
///
/// Mounts the contents of an archive file as a new read-only disk, for use by the CLI
@@ -508,8 +516,7 @@ public IEnumerable GetProfiles()
}
var otherDisks = GetOtherDiskOptions(excluding: null);
- if (otherDisks.Any(d => d.SourceArchivePath != null &&
- string.Equals(d.SourceArchivePath, archivePath, StringComparison.OrdinalIgnoreCase)))
+ if (IsPathInUse(otherDisks, archivePath, d => d.SourceArchivePath))
{
return (false, Loc.Get("Val.ArchivePathInUse"));
}
@@ -555,6 +562,7 @@ public IEnumerable GetProfiles()
/// Errors are surfaced via .
///
/// The profile to mount.
+ /// An optional progress reporter.
///
/// true if the disk was mounted successfully; false if mounting failed
/// (the failure reason is surfaced via ).
@@ -621,8 +629,7 @@ public async Task MountFromProfileAsync(DiskProfile profile, IProgress d.PersistImagePath != null &&
- string.Equals(d.PersistImagePath, imagePath, StringComparison.OrdinalIgnoreCase)))
+ if (IsPathInUse(otherDisks, imagePath, d => d.PersistImagePath))
{
return (false, Loc.Get("Val.ImagePathInUse"));
}
@@ -659,6 +666,7 @@ public async Task MountFromProfileAsync(DiskProfile profile, IProgress
- /// Finds the first free drive letter searching from Z: down to D:, skipping
- /// letters already in use by any Windows drive (mounted RAM disks included, since they show
- /// up in like any other volume).
- ///
- /// A free mount point (e.g. "Z:"), or null if none is free.
- private static string? FindFreeDriveLetter()
- {
- var usedLetters = new HashSet(
- DriveInfo.GetDrives().Select(d => char.ToUpperInvariant(d.Name[0])));
-
- for (var c = 'Z'; c >= 'D'; c--)
- {
- if (!usedLetters.Contains(c))
- {
- return $"{c}:";
- }
- }
-
- return null;
- }
-
- private static DiskOptions ProfileToOptions(DiskProfile p) => new()
+ internal static DiskOptions ProfileToOptions(DiskProfile p) => new()
{
MountPoint = p.MountPoint,
VolumeLabel = p.VolumeLabel,
@@ -872,6 +858,7 @@ internal void ShowDiskActivityStatus(string mountPoint, bool isWrite, string fil
SourceArchivePath = p.SourceArchivePath,
AutoSaveIntervalMinutes = p.AutoSaveIntervalMinutes,
CompressionLevel = p.CompressionLevel,
+ CustomZstdLevel = p.CustomZstdLevel,
MaxSnapshotCount = p.MaxSnapshotCount,
MaxSnapshotSizeBytes = p.MaxSnapshotSizeBytes,
HighUsageWarnPercent = p.HighUsageWarnPercent,
@@ -987,11 +974,7 @@ private async void ExecuteCloneDisk(DiskViewModel? vm)
if (!target.Disk.TryCloneFrom(vm.Disk, out var error))
{
_logger.LogWarning("Clone disk failed: {Source} -> {Target}: {Error}", vm.MountPoint, target.MountPoint, error);
- MessageBox.Show(
- error,
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Warning);
+ ShowWarning(error);
return;
}
@@ -1020,11 +1003,7 @@ private async void ExecuteCloneDisk(DiskViewModel? vm)
catch (Exception ex)
{
_logger.LogError(ex, "Disk export failed: {Source} -> {ExportPath}.", vm.MountPoint, exportPath);
- MessageBox.Show(
- Loc.Format("Msg.SaveImageFailed", ex.Message),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Error);
+ ShowError(Loc.Format("Msg.SaveImageFailed", ex.Message));
}
finally
{
@@ -1125,11 +1104,15 @@ private async void ExecuteEditDisk(DiskViewModel? vm)
try
{
- var disk = await Task.Run(() => _mountManager.Mount(newOptions, currentPassword));
- if (dialog.PasswordChanged)
+ var disk = await Task.Run(() =>
{
- disk.SetPassword(dialog.Password);
- }
+ var mounted = _mountManager.Mount(newOptions, currentPassword);
+ if (dialog.PasswordChanged)
+ {
+ mounted.SetPassword(dialog.Password);
+ }
+ return mounted;
+ });
vm.Dispose();
Disks.Remove(vm);
@@ -1143,11 +1126,7 @@ private async void ExecuteEditDisk(DiskViewModel? vm)
_logger.LogError(ex, "Edit disk remount failed: {OldMountPoint} -> {NewMountPoint}.", oldMountPoint, newOptions.MountPoint);
vm.Dispose();
Disks.Remove(vm);
- MessageBox.Show(
- Loc.Format("Msg.MountFailed", ex.Message),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Error);
+ ShowError(Loc.Format("Msg.MountFailed", ex.Message));
StatusText = Loc.Get("Status.MountFailed");
}
}
@@ -1196,7 +1175,7 @@ private void ExecuteExit()
return;
}
- var tempOnRamDisk = IsTempOnAnyRamDisk();
+ var tempOnRamDisk = TempDirCompatChecker.IsTempOnAnyDisk(Disks);
var body = Loc.Get("Msg.ExitConfirmBody");
if (tempOnRamDisk)
@@ -1245,22 +1224,14 @@ private void ExecuteFormatDisk(DiskViewModel? vm)
if (!vm.Disk.Format())
{
_logger.LogWarning("Format disk failed for {MountPoint}: disk is read-only.", vm.MountPoint);
- MessageBox.Show(
- Loc.Get("Msg.FormatDiskReadOnly"),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Warning);
+ ShowWarning(Loc.Get("Msg.FormatDiskReadOnly"));
return;
}
vm.Refresh();
StatusText = Loc.Format("Status.FormatDisk", vm.MountPoint);
_logger.LogInformation("Format disk completed for {MountPoint}.", vm.MountPoint);
- MessageBox.Show(
- Loc.Format("Msg.FormatDiskSuccess", vm.MountPoint),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Information);
+ ShowInfo(Loc.Format("Msg.FormatDiskSuccess", vm.MountPoint));
}
private async void ExecuteImportArchive()
@@ -1278,14 +1249,9 @@ private async void ExecuteImportArchive()
}
var otherDisks = GetOtherDiskOptions(excluding: null);
- if (otherDisks.Any(d => d.SourceArchivePath != null &&
- string.Equals(d.SourceArchivePath, openDialog.FileName, StringComparison.OrdinalIgnoreCase)))
- {
- MessageBox.Show(
- Loc.Get("Val.ArchivePathInUse"),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Warning);
+ if (IsPathInUse(otherDisks, openDialog.FileName, d => d.SourceArchivePath))
+ {
+ ShowWarning(Loc.Get("Val.ArchivePathInUse"));
return;
}
@@ -1297,18 +1263,12 @@ private async void ExecuteImportArchive()
}
catch (InvalidDataException)
{
- MessageBox.Show(
- Loc.Get("Val.ImportInvalidArchive"),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Warning);
+ ShowWarning(Loc.Get("Val.ImportInvalidArchive"));
return;
}
- var dialog = new CreateDiskDialog(openDialog.FileName, totalBytes, suggestedLabel, otherDisks, isArchiveImport: true)
- {
- Owner = Application.Current.MainWindow
- };
+ var dialog = CreateDiskDialog.ForArchiveImport(openDialog.FileName, totalBytes, suggestedLabel, otherDisks);
+ dialog.Owner = Application.Current.MainWindow;
if (dialog.ShowDialog() != true)
{
@@ -1343,14 +1303,9 @@ private async void ExecuteImportDisk()
}
var otherDisks = GetOtherDiskOptions(excluding: null);
- if (otherDisks.Any(d => d.PersistImagePath != null &&
- string.Equals(d.PersistImagePath, openDialog.FileName, StringComparison.OrdinalIgnoreCase)))
- {
- MessageBox.Show(
- Loc.Get("Val.ImagePathInUse"),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Warning);
+ if (IsPathInUse(otherDisks, openDialog.FileName, d => d.PersistImagePath))
+ {
+ ShowWarning(Loc.Get("Val.ImagePathInUse"));
return;
}
@@ -1362,11 +1317,7 @@ private async void ExecuteImportDisk()
}
catch (InvalidDataException)
{
- MessageBox.Show(
- Loc.Get("Val.ImportInvalidImage"),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Warning);
+ ShowWarning(Loc.Get("Val.ImportInvalidImage"));
return;
}
@@ -1415,20 +1366,12 @@ private async void ExecuteResetTempDirs()
if (success)
{
_logger.LogInformation("Reset TEMP dirs succeeded.");
- MessageBox.Show(
- Loc.Get("Msg.ResetTempSuccess"),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Information);
+ ShowInfo(Loc.Get("Msg.ResetTempSuccess"));
}
else
{
_logger.LogWarning("Reset TEMP dirs failed.");
- MessageBox.Show(
- Loc.Get("Msg.ResetTempFailed"),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Error);
+ ShowError(Loc.Get("Msg.ResetTempFailed"));
}
}
@@ -1442,11 +1385,7 @@ private async void ExecuteRestoreSnapshot(DiskViewModel? vm)
var snapshots = await Task.Run(() => SnapshotManager.ListSnapshots(imagePath));
if (snapshots.Count == 0)
{
- MessageBox.Show(
- Loc.Get("Msg.NoSnapshotsAvailable"),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Information);
+ ShowInfo(Loc.Get("Msg.NoSnapshotsAvailable"));
return;
}
@@ -1479,11 +1418,7 @@ private async void ExecuteRestoreSnapshot(DiskViewModel? vm)
if (!success)
{
_logger.LogWarning("Restore snapshot failed for {MountPoint}: {Error}", vm.MountPoint, error);
- MessageBox.Show(
- error,
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Warning);
+ ShowWarning(error);
return;
}
@@ -1534,11 +1469,7 @@ private async void ExecuteSaveImage(DiskViewModel? vm)
catch (Exception ex)
{
_logger.LogError(ex, "Save image failed for {MountPoint}.", vm.MountPoint);
- MessageBox.Show(
- Loc.Format("Msg.SaveImageFailed", ex.Message),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Error);
+ ShowError(Loc.Format("Msg.SaveImageFailed", ex.Message));
}
finally
{
@@ -1575,21 +1506,13 @@ private async void ExecuteToggleTempDir(DiskViewModel? vm)
if (success)
{
- MessageBox.Show(
- Loc.Get("Msg.ResetTempSuccess"),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Information);
+ ShowInfo(Loc.Get("Msg.ResetTempSuccess"));
vm.Refresh();
}
else
{
_logger.LogWarning("TEMP dir reset failed.");
- MessageBox.Show(
- Loc.Get("Msg.ResetTempFailed"),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Error);
+ ShowError(Loc.Get("Msg.ResetTempFailed"));
}
}
else
@@ -1617,22 +1540,14 @@ private async void ExecuteToggleTempDir(DiskViewModel? vm)
if (success)
{
- MessageBox.Show(
- Loc.Format("Msg.SetTempDirSuccess", tempPath),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Information);
+ ShowInfo(Loc.Format("Msg.SetTempDirSuccess", tempPath));
StatusText = Loc.Format("Status.TempDirSet", tempPath);
vm.Refresh();
}
else
{
_logger.LogWarning("Set TEMP dir failed: {TempPath}.", tempPath);
- MessageBox.Show(
- Loc.Get("Msg.SetTempDirFailed"),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Error);
+ ShowError(Loc.Get("Msg.SetTempDirFailed"));
}
}
}
@@ -1710,14 +1625,6 @@ private void ExecuteViewDiskContents(DiskViewModel? vm)
private IReadOnlyList GetOtherDiskOptions(DiskViewModel? excluding) =>
Disks.Where(d => d != excluding).Select(d => d.Disk.Options).ToList();
- private bool IsTempOnAnyRamDisk()
- {
- var userTemp = Environment.GetEnvironmentVariable("TEMP", EnvironmentVariableTarget.User);
- var expandedTemp = string.IsNullOrEmpty(userTemp) ? null : Environment.ExpandEnvironmentVariables(userTemp);
- return expandedTemp != null &&
- Disks.Any(d => expandedTemp.StartsWith(d.MountPoint, StringComparison.OrdinalIgnoreCase));
- }
-
private async Task MountAndAddAsync(DiskOptions options, string? password = null, IProgress? progress = null)
{
try
@@ -1737,11 +1644,7 @@ private async Task MountAndAddAsync(DiskOptions options, string? password = null
catch (Exception ex)
{
_logger.LogError(ex, "Mount failed for {MountPoint}.", options.MountPoint);
- MessageBox.Show(
- Loc.Format("Msg.MountFailed", ex.Message),
- "ManagedDrive",
- MessageBoxButton.OK,
- MessageBoxImage.Error);
+ ShowError(Loc.Format("Msg.MountFailed", ex.Message));
StatusText = Loc.Get("Status.MountFailed");
}
}
diff --git a/src/ManagedDrive.App/ViewModels/MainViewModelHelpers.cs b/src/ManagedDrive.App/ViewModels/MainViewModelHelpers.cs
new file mode 100644
index 0000000..e908b18
--- /dev/null
+++ b/src/ManagedDrive.App/ViewModels/MainViewModelHelpers.cs
@@ -0,0 +1,63 @@
+namespace ManagedDrive.App.ViewModels;
+
+///
+/// Stateless helper methods extracted from : pure functions with no
+/// dependency on instance state (mount manager, disk collection, settings store), pulled out to
+/// keep the view model focused on stateful command handling. Consumed via
+/// using static ManagedDrive.App.ViewModels.MainViewModelHelpers; in MainViewModel.cs
+/// so call sites stay unqualified.
+///
+internal static class MainViewModelHelpers
+{
+ ///
+ /// Returns whether any disk in already has
+ /// set on the field selected by (e.g.
+ /// or ). Shared by every image/archive path
+ /// collision check (create, edit, import, CLI mount).
+ ///
+ public static bool IsPathInUse(IReadOnlyList otherDisks, string path, Func selector) =>
+ otherDisks.Any(d => selector(d) is { } p && string.Equals(p, path, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Finds the first free drive letter searching from Z: down to D:, skipping
+ /// letters already in use by any Windows drive (mounted RAM disks included, since they show
+ /// up in like any other volume).
+ ///
+ /// A free mount point (e.g. "Z:"), or null if none is free.
+ public static string? FindFreeDriveLetter()
+ {
+ var usedLetters = new HashSet(
+ DriveInfo.GetDrives().Select(d => char.ToUpperInvariant(d.Name[0])));
+
+ for (var c = 'Z'; c >= 'D'; c--)
+ {
+ if (!usedLetters.Contains(c))
+ {
+ return $"{c}:";
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Shows a modal with the "ManagedDrive" title and an error icon.
+ /// Shared boilerplate for the many failure paths across that don't
+ /// need a custom title (contrast ExecuteEditDisk's inline apply-failure message, which
+ /// reuses Msg.EditDiskConfirmTitle as its title instead).
+ ///
+ public static void ShowError(string message) =>
+ MessageBox.Show(message, "ManagedDrive", MessageBoxButton.OK, MessageBoxImage.Error);
+
+ ///
+ /// Shows a modal with the "ManagedDrive" title and an info icon.
+ ///
+ public static void ShowInfo(string message) =>
+ MessageBox.Show(message, "ManagedDrive", MessageBoxButton.OK, MessageBoxImage.Information);
+
+ ///
+ /// Shows a modal with the "ManagedDrive" title and a warning icon.
+ ///
+ public static void ShowWarning(string? message) =>
+ MessageBox.Show(message, "ManagedDrive", MessageBoxButton.OK, MessageBoxImage.Warning);
+}
diff --git a/src/ManagedDrive.App/Views/AboutDialog.xaml b/src/ManagedDrive.App/Views/AboutDialog.xaml
index 756d84e..e74df0b 100644
--- a/src/ManagedDrive.App/Views/AboutDialog.xaml
+++ b/src/ManagedDrive.App/Views/AboutDialog.xaml
@@ -57,25 +57,10 @@
-
-
+
+
-
-
-
-
-
-
-
diff --git a/src/ManagedDrive.App/Views/AboutDialog.xaml.cs b/src/ManagedDrive.App/Views/AboutDialog.xaml.cs
index e9852f0..0c99fd7 100644
--- a/src/ManagedDrive.App/Views/AboutDialog.xaml.cs
+++ b/src/ManagedDrive.App/Views/AboutDialog.xaml.cs
@@ -8,8 +8,7 @@ namespace ManagedDrive.App.Views;
public partial class AboutDialog
{
private const string GitHubUrl = "https://github.com/coldhighsun/ManagedDrive";
- private const string SharpCompressUrl = "https://github.com/adamhathcock/sharpcompress";
- private const string WinFspUrl = "https://winfsp.dev/";
+ private const string ThirdPartyNoticesUrl = "https://github.com/coldhighsun/ManagedDrive/blob/main/THIRD-PARTY-NOTICES.md";
private readonly UpdateCheckService? _updateCheckService;
public AboutDialog(UpdateCheckService? updateCheckService = null)
@@ -19,8 +18,7 @@ public AboutDialog(UpdateCheckService? updateCheckService = null)
VersionText.Text = UpdateCheckService.GetRunningVersion();
GitHubLink.NavigateUri = new(GitHubUrl);
- WinFspLink.NavigateUri = new(WinFspUrl);
- SharpCompressLink.NavigateUri = new(SharpCompressUrl);
+ ThirdPartyNoticesLink.NavigateUri = new(ThirdPartyNoticesUrl);
_ = CheckForUpdateAsync();
}
diff --git a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml
index 9c429b8..1056a98 100644
--- a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml
+++ b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml
@@ -14,7 +14,7 @@
-
+
@@ -140,6 +140,8 @@
+
+
@@ -205,30 +207,54 @@
SelectionChanged="CompressionLevelBox_SelectionChanged"/>
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
@@ -253,11 +279,11 @@
-
-
+
@@ -287,7 +313,7 @@
-
+
@@ -316,7 +342,7 @@
-
+
diff --git a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs
index 89f9afa..11e460e 100644
--- a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs
+++ b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs
@@ -26,6 +26,7 @@ public partial class CreateDiskDialog
private readonly bool _wasEncrypted;
private int _capacityMaximum = 99999999;
private int _capacityValue = 2;
+ private int _customZstdLevelValue = 3;
private int _highUsageWarnPercentValue = 90;
private int _intervalValue = 10;
private int _snapshotCountValue = 10;
@@ -69,6 +70,7 @@ public CreateDiskDialog(IReadOnlyList? otherDisks = null)
}
CompressionLevelBox.SelectedIndex = CompressionLevels.IndexOf(ImageCompressionLevel.Fastest);
+ CustomZstdLevelValue = _customZstdLevelValue;
UpdateCompressionLevelState();
UpdateAutoSaveEnabledState();
UpdateHighUsageWarnPercentState();
@@ -121,6 +123,13 @@ public CreateDiskDialog(DiskOptions existing, IReadOnlyList? otherD
ImagePathBox.Text = existing.PersistImagePath ?? string.Empty;
CompressionLevelBox.SelectedIndex = CompressionLevels.IndexOf(existing.CompressionLevel);
SaveOnExitBox.IsChecked = existing.SaveImageOnExit;
+
+ if (existing.CustomZstdLevel is { } customZstdLevel)
+ {
+ CustomZstdLevelBox.IsChecked = true;
+ CustomZstdLevelValue = customZstdLevel;
+ }
+
UpdateCompressionLevelState();
UpdateAutoSaveEnabledState();
@@ -215,14 +224,7 @@ public CreateDiskDialog(string importImagePath, ulong importCapacityBytes, strin
ClearImagePathButton.IsEnabled = false;
ImportNoteText.Visibility = Visibility.Visible;
- var (importCapValue, importCapIsGb) = ByteUnitConverter.SplitToUnit(importCapacityBytes);
- CapacityUnitBox.SelectedItem = importCapIsGb ? "GB" : "MB";
- _capacityValue = importCapValue;
-
- _capacityMaximum = Math.Max(_capacityValue, GetMaxCapacityValue());
- CapacitySlider.Maximum = _capacityMaximum;
- UpdateCapacityDisplay();
- VolumeLabelBox.Text = importVolumeLabel;
+ ApplyLockedCapacityAndLabel(importCapacityBytes, importVolumeLabel);
CapacitySlider.IsEnabled = false;
CapacityUnitBox.IsEnabled = false;
@@ -242,7 +244,10 @@ public CreateDiskDialog(string importImagePath, ulong importCapacityBytes, strin
/// Initializes the dialog in archive-import mode: capacity and volume label are pre-filled
/// from the archive's contents and locked, the disk is forced read-only (archive formats
/// don't support writing changes back), and the entire Persistence tab is disabled since an
- /// archive-sourced disk has no backing image file to save to.
+ /// archive-sourced disk has no backing image file to save to. Private: use
+ /// to construct one of these — an unused trailing parameter is
+ /// still needed to give this constructor a distinct signature from the image-import one
+ /// above, but it is now a private implementation detail instead of a public API wart.
///
/// Path of the archive file to import.
/// Total uncompressed size of the archive's contents, used to pre-fill and lock the capacity fields.
@@ -251,11 +256,10 @@ public CreateDiskDialog(string importImagePath, ulong importCapacityBytes, strin
/// Options of all other currently active disks, used to validate that the archive file path
/// does not collide with another disk's mount point.
///
- /// Always true; disambiguate this overload from the image-import constructor.
- public CreateDiskDialog(string importArchivePath, ulong importTotalBytes, string importVolumeLabel,
- IReadOnlyList otherDisks, bool isArchiveImport) : this(otherDisks)
+ /// Unused parameter to differentiate the constructor signature.
+ private CreateDiskDialog(string importArchivePath, ulong importTotalBytes, string importVolumeLabel,
+ IReadOnlyList otherDisks, bool archiveImportOverloadTag) : this(otherDisks)
{
- _ = isArchiveImport;
_isImportMode = true;
_isArchiveImportMode = true;
_importArchivePath = importArchivePath;
@@ -269,14 +273,7 @@ public CreateDiskDialog(string importArchivePath, ulong importTotalBytes, string
ClearImagePathButton.IsEnabled = false;
ArchiveImportNoteText.Visibility = Visibility.Visible;
- var (archiveCapValue, archiveCapIsGb) = ByteUnitConverter.SplitToUnit(importTotalBytes);
- CapacityUnitBox.SelectedItem = archiveCapIsGb ? "GB" : "MB";
- _capacityValue = archiveCapValue;
-
- _capacityMaximum = Math.Max(_capacityValue, GetMaxCapacityValue());
- CapacitySlider.Maximum = _capacityMaximum;
- UpdateCapacityDisplay();
- VolumeLabelBox.Text = importVolumeLabel;
+ ApplyLockedCapacityAndLabel(importTotalBytes, importVolumeLabel);
CapacityRow.IsEnabled = false;
VolumeLabelBox.IsEnabled = false;
@@ -293,6 +290,21 @@ public CreateDiskDialog(string importArchivePath, ulong importTotalBytes, string
UpdateAutoSaveEnabledState();
}
+ ///
+ /// Creates a in archive-import mode (see the private
+ /// constructor above for details).
+ ///
+ /// Path of the archive file to import.
+ /// Total uncompressed size of the archive's contents, used to pre-fill and lock the capacity fields.
+ /// Suggested volume label (derived from the archive's file name), used to pre-fill and lock the label field.
+ ///
+ /// Options of all other currently active disks, used to validate that the archive file path
+ /// does not collide with another disk's mount point.
+ ///
+ public static CreateDiskDialog ForArchiveImport(string importArchivePath, ulong importTotalBytes, string importVolumeLabel,
+ IReadOnlyList otherDisks) =>
+ new(importArchivePath, importTotalBytes, importVolumeLabel, otherDisks, archiveImportOverloadTag: true);
+
///
/// The plaintext password entered by the user, when is
/// true and this is non-null (set/change password); null together with
@@ -334,6 +346,17 @@ private int CapacityValue
}
}
+ private int CustomZstdLevelValue
+ {
+ get => _customZstdLevelValue;
+ set
+ {
+ _customZstdLevelValue = Math.Clamp(value, 1, 22);
+ CustomZstdLevelSlider.Value = _customZstdLevelValue;
+ CustomZstdLevelValueText?.Text = _customZstdLevelValue.ToString();
+ }
+ }
+
private int HighUsageWarnPercentValue
{
get => _highUsageWarnPercentValue;
@@ -436,6 +459,8 @@ private CreateDiskInput BuildInput()
HighUsageWarnPercentValue = _highUsageWarnPercentValue,
CompressionLevel = (CompressionLevelBox.SelectedItem as CompressionLevelItem)?.Level
?? ImageCompressionLevel.Fastest,
+ CustomZstdLevelEnabled = CustomZstdLevelRow.IsEnabled && CustomZstdLevelBox.IsChecked == true,
+ CustomZstdLevelValue = _customZstdLevelValue,
SaveImageOnExit = SaveOnExitBox.IsChecked == true,
EncryptChecked = EncryptImageBox.IsChecked == true,
Password1 = PasswordBox1.Password,
@@ -475,7 +500,36 @@ private void ClearImagePath_Click(object sender, RoutedEventArgs e)
private void CompressionLevelBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
- UpdateCompressionWarning();
+ UpdateCustomZstdLevelRowState();
+ }
+
+ private void CustomZstdLevelBox_CheckedChanged(object sender, RoutedEventArgs e)
+ {
+ UpdateCustomZstdLevelRowState();
+ }
+
+ private void CustomZstdLevelSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
+ {
+ CustomZstdLevelValue = (int)e.NewValue;
+ }
+
+ ///
+ /// Pre-fills the capacity slider/unit and volume label from a size/label pair that's locked
+ /// against user edits (an imported image's or archive's own capacity and label). Shared by
+ /// the image-import and archive-import constructors; each still sets its own combination of
+ /// IsEnabled = false afterward, since which specific controls get locked differs
+ /// between the two modes.
+ ///
+ private void ApplyLockedCapacityAndLabel(ulong capacityBytes, string label)
+ {
+ var (value, isGb) = ByteUnitConverter.SplitToUnit(capacityBytes);
+ CapacityUnitBox.SelectedItem = isGb ? "GB" : "MB";
+ _capacityValue = value;
+
+ _capacityMaximum = Math.Max(_capacityValue, GetMaxCapacityValue());
+ CapacitySlider.Maximum = _capacityMaximum;
+ UpdateCapacityDisplay();
+ VolumeLabelBox.Text = label;
}
private int ComputeMaxValueForUnit(bool isGb) => ByteUnitConverter.MaxValueForUnit(_maxCapacityBytes, isGb);
@@ -557,6 +611,7 @@ private void LoadDriveLetters(char? reservedLetter)
CreateDiskValidationError.BadSnapshotCount => Loc.Get("Val.BadSnapshotCount"),
CreateDiskValidationError.BadSnapshotSize => Loc.Get("Val.BadSnapshotSize"),
CreateDiskValidationError.BadHighUsagePercent => Loc.Get("Val.BadHighUsagePercent"),
+ CreateDiskValidationError.BadCustomZstdLevel => Loc.Get("Val.BadCustomZstdLevel"),
CreateDiskValidationError.PasswordRequired => Loc.Get("Val.PasswordRequired"),
CreateDiskValidationError.PasswordMismatch => Loc.Get("Val.PasswordMismatch"),
CreateDiskValidationError.PasswordTooShort =>
@@ -729,17 +784,29 @@ private void UpdateCompressionLevelState()
// Toggle the whole row (label + combo) so the label greys out with the control when a
// read-only disk has nothing to compress.
CompressionLevelRow.IsEnabled = !string.IsNullOrEmpty(ImagePathBox.Text) && ReadOnlyBox.IsChecked != true;
- UpdateCompressionWarning();
+ UpdateCustomZstdLevelRowState();
}
- private void UpdateCompressionWarning()
+ ///
+ /// The custom-Zstd-level override only makes sense when compression is actually happening —
+ /// disabled whenever the main compression row is disabled (read-only/no image), or when
+ /// is selected (nothing to compress at all).
+ ///
+ private void UpdateCustomZstdLevelRowState()
{
- if (CompressionWarningText is null)
- return;
var level = (CompressionLevelBox.SelectedItem as CompressionLevelItem)?.Level;
- var show = CompressionLevelRow.IsEnabled
- && level is ImageCompressionLevel.Optimal or ImageCompressionLevel.SmallestSize;
- CompressionWarningText.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
+ CustomZstdLevelRow.IsEnabled = CompressionLevelRow.IsEnabled && level != ImageCompressionLevel.None;
+ if (!CustomZstdLevelRow.IsEnabled)
+ {
+ CustomZstdLevelBox.IsChecked = false;
+ }
+
+ var customEnabled = CustomZstdLevelBox.IsChecked == true;
+ CustomZstdLevelPanel.IsEnabled = customEnabled;
+
+ // A custom Zstd level overrides the preset entirely, so grey out the preset dropdown
+ // while it's active rather than leaving it selectable but silently ignored.
+ CompressionLevelBox.IsEnabled = CompressionLevelRow.IsEnabled && !customEnabled;
}
private void UpdateHighUsageWarnPercentState()
diff --git a/src/ManagedDrive.Cli.Core/CliCommandProcessor.cs b/src/ManagedDrive.Cli.Core/CliCommandProcessor.cs
index 61783b2..23f1c26 100644
--- a/src/ManagedDrive.Cli.Core/CliCommandProcessor.cs
+++ b/src/ManagedDrive.Cli.Core/CliCommandProcessor.cs
@@ -43,6 +43,10 @@ public static async Task ExecuteAsync(string[] args, ICliDiskControl
{
Description = "Image compression level: None, Fastest, Optimal, or SmallestSize. If omitted, keeps the saved profile's value (or the default: Fastest).",
};
+ var mountCustomZstdLevelOption = new Option("--custom-zstd-level")
+ {
+ Description = "Custom Zstd compression level (1-22), overriding the preset mapping for --compression. Only takes effect when the compression level is not None.",
+ };
var mountMaxSnapshotCountOption = new Option("--max-snapshot-count")
{
Description = "Maximum number of retained snapshots. If omitted, keeps the saved profile's value (or the default: unlimited).",
@@ -71,6 +75,7 @@ public static async Task ExecuteAsync(string[] args, ICliDiskControl
mountCommand.Options.Add(mountAutoMountOption);
mountCommand.Options.Add(mountAutoSaveMinutesOption);
mountCommand.Options.Add(mountCompressionOption);
+ mountCommand.Options.Add(mountCustomZstdLevelOption);
mountCommand.Options.Add(mountMaxSnapshotCountOption);
mountCommand.Options.Add(mountMaxSnapshotSizeMbOption);
mountCommand.Options.Add(mountHighUsageWarnPercentOption);
@@ -79,9 +84,16 @@ public static async Task ExecuteAsync(string[] args, ICliDiskControl
mountCommand.SetAction(async (parseResult, _) =>
{
var maxSnapshotSizeMb = parseResult.GetValue(mountMaxSnapshotSizeMbOption);
+ var customZstdLevel = parseResult.GetValue(mountCustomZstdLevelOption);
var password = parseResult.GetValue(mountPasswordOption);
var passwordFile = parseResult.GetValue(mountPasswordFileOption);
+ if (customZstdLevel is < 1 or > 22)
+ {
+ outcome = new CliOutcome(false, "--custom-zstd-level must be between 1 and 22.", null, 1);
+ return 1;
+ }
+
if (password is not null && passwordFile is not null)
{
outcome = new CliOutcome(false, "--password and --password-file cannot both be specified.", null, 1);
@@ -107,6 +119,7 @@ public static async Task ExecuteAsync(string[] args, ICliDiskControl
AutoMount = parseResult.GetValue(mountAutoMountOption),
AutoSaveIntervalMinutes = parseResult.GetValue(mountAutoSaveMinutesOption),
CompressionLevel = parseResult.GetValue(mountCompressionOption),
+ CustomZstdLevel = customZstdLevel,
MaxSnapshotCount = parseResult.GetValue(mountMaxSnapshotCountOption),
MaxSnapshotSizeBytes = maxSnapshotSizeMb * 1024UL * 1024UL,
HighUsageWarnPercent = parseResult.GetValue(mountHighUsageWarnPercentOption),
diff --git a/src/ManagedDrive.Cli.Core/CliMountOverrides.cs b/src/ManagedDrive.Cli.Core/CliMountOverrides.cs
index e4ff47d..d7ec254 100644
--- a/src/ManagedDrive.Cli.Core/CliMountOverrides.cs
+++ b/src/ManagedDrive.Cli.Core/CliMountOverrides.cs
@@ -37,6 +37,16 @@ public ImageCompressionLevel? CompressionLevel
get; init;
}
+ ///
+ /// Gets the custom Zstd compression level (1-22) to use instead of the preset mapping for
+ /// . Only takes effect when the compression level is not
+ /// .
+ ///
+ public int? CustomZstdLevel
+ {
+ get; init;
+ }
+
///
/// Gets the maximum number of snapshots to retain.
///
diff --git a/src/ManagedDrive.Core/DiskCreation/CreateDiskOptionsBuilder.cs b/src/ManagedDrive.Core/DiskCreation/CreateDiskOptionsBuilder.cs
index dda18f2..23e48dc 100644
--- a/src/ManagedDrive.Core/DiskCreation/CreateDiskOptionsBuilder.cs
+++ b/src/ManagedDrive.Core/DiskCreation/CreateDiskOptionsBuilder.cs
@@ -76,6 +76,9 @@ public enum CreateDiskValidationError
/// The high-usage warning percentage was out of range.
BadHighUsagePercent,
+ /// The custom Zstd compression level was out of range.
+ BadCustomZstdLevel,
+
/// Encryption was enabled but no password was entered.
PasswordRequired,
@@ -162,6 +165,12 @@ public sealed record CreateDiskInput
/// The selected compression level.
public ImageCompressionLevel CompressionLevel { get; init; } = ImageCompressionLevel.Fastest;
+ /// Whether the advanced custom-Zstd-level override is enabled.
+ public bool CustomZstdLevelEnabled { get; init; }
+
+ /// The custom Zstd level (1-22), used only when is set.
+ public int CustomZstdLevelValue { get; init; }
+
/// Whether to save the image on exit.
public bool SaveImageOnExit { get; init; }
@@ -349,6 +358,17 @@ public static CreateDiskBuildResult Build(CreateDiskInput input)
return Fail(CreateDiskValidationError.BadHighUsagePercent);
}
+ int? customZstdLevel = null;
+ if (input.CompressionLevel != ImageCompressionLevel.None && input.CustomZstdLevelEnabled)
+ {
+ if (input.CustomZstdLevelValue < 1 || input.CustomZstdLevelValue > 22)
+ {
+ return Fail(CreateDiskValidationError.BadCustomZstdLevel);
+ }
+
+ customZstdLevel = input.CustomZstdLevelValue;
+ }
+
var passwordResult = ResolvePassword(input);
if (!passwordResult.Success)
{
@@ -365,6 +385,7 @@ public static CreateDiskBuildResult Build(CreateDiskInput input)
PersistImagePath = imagePath,
AutoSaveIntervalMinutes = autoSaveIntervalMinutes,
CompressionLevel = input.CompressionLevel,
+ CustomZstdLevel = customZstdLevel,
MaxSnapshotCount = maxSnapshotCount,
MaxSnapshotSizeBytes = maxSnapshotSizeBytes,
HighUsageWarnPercent = highUsageWarnPercent,
diff --git a/src/ManagedDrive.Core/FileSystem/FileNode.cs b/src/ManagedDrive.Core/FileSystem/FileNode.cs
index d29e1dd..cab02a8 100644
--- a/src/ManagedDrive.Core/FileSystem/FileNode.cs
+++ b/src/ManagedDrive.Core/FileSystem/FileNode.cs
@@ -24,6 +24,27 @@ public sealed class FileNode
///
public byte[]? FileSecurity;
+ ///
+ /// Bumped by every time
+ /// 's bytes or logical length actually change (write, truncate,
+ /// overwrite). Lets callers such as SnapshotManager.ComputeHash cache a content
+ /// hash and cheaply detect whether it is still valid, without relying on wall-clock
+ /// timestamps (which callers other than the WinFsp write path aren't guaranteed to advance
+ /// in lockstep with content).
+ ///
+ internal ulong ContentVersion;
+
+ ///
+ /// The content hash last computed for this node, cached against
+ /// at the time it was computed. null when no hash has been computed yet.
+ ///
+ internal byte[]? CachedContentHash;
+
+ ///
+ /// The value was computed for.
+ ///
+ internal ulong CachedContentHashVersion;
+
///
/// The allocation granularity in bytes. All allocation sizes are rounded up to this boundary.
///
diff --git a/src/ManagedDrive.Core/FileSystem/FileNodeMap.cs b/src/ManagedDrive.Core/FileSystem/FileNodeMap.cs
index 38f000c..ca3a43b 100644
--- a/src/ManagedDrive.Core/FileSystem/FileNodeMap.cs
+++ b/src/ManagedDrive.Core/FileSystem/FileNodeMap.cs
@@ -7,7 +7,13 @@ namespace ManagedDrive.Core.FileSystem;
///
public sealed class FileNodeMap : IDisposable
{
- private readonly SortedDictionary _map = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary _map = new(StringComparer.OrdinalIgnoreCase);
+
+ // Parallel key index so directory enumeration (GetChildren) can seek directly to a path
+ // prefix's range in O(log n) via GetViewBetween, instead of scanning the whole namespace
+ // from the start looking for where the prefix run begins. _map itself is a plain Dictionary
+ // (O(1) lookup/insert/remove) precisely because it no longer needs to maintain order.
+ private readonly SortedSet _sortedKeys = new(StringComparer.OrdinalIgnoreCase);
// Read/write lock instead of a mutual-exclusion lock: lookups and directory enumerations
// (the read-heavy majority) can proceed concurrently, and a full-scan enumeration no longer
@@ -50,6 +56,10 @@ public void Add(string filePath, FileNode node)
{
_totalAllocated -= existing.FileInfo.AllocationSize;
}
+ else
+ {
+ _sortedKeys.Add(filePath);
+ }
node.FilePath = filePath;
node.LeafName = ComputeLeafName(filePath);
@@ -70,19 +80,16 @@ public void ClearAll()
_syncRoot.EnterWriteLock();
try
{
- var toRemove = new List();
- foreach (var key in _map.Keys)
- {
- if (key != "\\")
- {
- toRemove.Add(key);
- }
- }
+ var hasRoot = _map.TryGetValue("\\", out var root);
+ _map.Clear();
+ _sortedKeys.Clear();
+ _totalAllocated = 0;
- foreach (var key in toRemove)
+ if (hasRoot)
{
- _totalAllocated -= _map[key].FileInfo.AllocationSize;
- _map.Remove(key);
+ _map["\\"] = root!;
+ _sortedKeys.Add("\\");
+ _totalAllocated = root!.FileInfo.AllocationSize;
}
}
finally
@@ -102,7 +109,13 @@ public IReadOnlyList> GetAllNodes()
_syncRoot.EnterReadLock();
try
{
- return [.. _map];
+ var result = new List>(_map.Count);
+ foreach (var key in _sortedKeys)
+ {
+ result.Add(new(key, _map[key]));
+ }
+
+ return result;
}
finally
{
@@ -127,26 +140,19 @@ public IEnumerable> GetChildren(string dirPath, s
// For root "\" (length 1) the prefix equals dirPath itself; for others append "\"
var prefix = dirPath.Length == 1 ? dirPath : (dirPath + "\\");
+ // All keys sharing this prefix form a contiguous run in _sortedKeys (OrdinalIgnoreCase
+ // order). GetViewBetween seeks directly to that range in O(log n) instead of scanning
+ // the whole namespace from the start looking for where the run begins — the upper bound
+ // uses '', a value greater than any character used in a real path, so the view
+ // covers exactly "prefix" plus everything that starts with it.
+ var upperBound = prefix + '';
+
List> matches = [];
_syncRoot.EnterReadLock();
try
{
- // _map is sorted (OrdinalIgnoreCase), so all keys sharing this prefix form a
- // contiguous run. Skip until it starts, collect while it holds, then stop.
- foreach (var kvp in _map)
+ foreach (var path in _sortedKeys.GetViewBetween(prefix, upperBound))
{
- var path = kvp.Key;
-
- if (!path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
- {
- if (matches.Count > 0)
- {
- break;
- }
-
- continue;
- }
-
if (string.Equals(path, dirPath, StringComparison.OrdinalIgnoreCase))
{
continue;
@@ -165,7 +171,7 @@ public IEnumerable> GetChildren(string dirPath, s
continue;
}
- matches.Add(kvp);
+ matches.Add(new(path, _map[path]));
}
}
finally
@@ -206,6 +212,7 @@ public void Remove(string filePath)
{
if (_map.Remove(filePath, out var removed))
{
+ _sortedKeys.Remove(filePath);
_totalAllocated -= removed.FileInfo.AllocationSize;
}
}
@@ -227,23 +234,19 @@ public void RenameDescendants(string oldPath, string newPath)
try
{
var prefix = oldPath + "\\";
- var keys = new List();
- foreach (var key in _map.Keys)
- {
- if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
- {
- keys.Add(key);
- }
- }
+ var upperBound = prefix + '';
+ var keys = new List(_sortedKeys.GetViewBetween(prefix, upperBound));
foreach (var key in keys)
{
var descendant = _map[key];
_map.Remove(key);
+ _sortedKeys.Remove(key);
var newKey = string.Concat(newPath, key.AsSpan(oldPath.Length));
descendant.FilePath = newKey;
descendant.LeafName = ComputeLeafName(newKey);
_map[newKey] = descendant;
+ _sortedKeys.Add(newKey);
}
}
finally
diff --git a/src/ManagedDrive.Core/FileSystem/MemoryFileSystem.cs b/src/ManagedDrive.Core/FileSystem/MemoryFileSystem.cs
index 432ae40..352734e 100644
--- a/src/ManagedDrive.Core/FileSystem/MemoryFileSystem.cs
+++ b/src/ManagedDrive.Core/FileSystem/MemoryFileSystem.cs
@@ -493,6 +493,7 @@ public override int Overwrite(
NodeMap.UpdateAllocationSize(node, aligned);
node.FileInfo.FileSize = 0;
node.FileData = aligned > 0 ? FileContent.CreateZeroed(aligned) : null;
+ node.ContentVersion++;
var now = FileTimeNow();
node.FileInfo.LastAccessTime = now;
@@ -805,18 +806,20 @@ public override int Write(
if (length > 0 && node.FileData != null)
{
node.FileData.WriteFrom(buffer, writeOffset, length);
+ node.ContentVersion++;
}
bytesTransferred = length;
Interlocked.Add(ref _totalBytesWritten, length);
- var now = FileTimeNow();
+ var nowOffset = DateTimeOffset.UtcNow;
+ var now = (ulong)nowOffset.ToFileTime();
node.FileInfo.LastAccessTime = now;
node.FileInfo.LastWriteTime = now;
node.FileInfo.ChangeTime = now;
- MarkDirty();
- Interlocked.Exchange(ref _lastContentWriteAccess, new(DateTimeOffset.UtcNow, node.FilePath));
+ MarkDirty(nowOffset);
+ Interlocked.Exchange(ref _lastContentWriteAccess, new(nowOffset, node.FilePath));
fileInfo = node.FileInfo;
return STATUS_SUCCESS;
}
@@ -829,10 +832,17 @@ public override int Write(
///
/// Marks the disk's content as changed since the last save.
///
- internal void MarkDirty()
+ internal void MarkDirty() => MarkDirty(DateTimeOffset.UtcNow);
+
+ ///
+ /// Marks the disk's content as changed since the last save, using a caller-supplied
+ /// timestamp to avoid redundant calls on hot paths
+ /// that already captured "now" for other purposes.
+ ///
+ private void MarkDirty(DateTimeOffset now)
{
_isDirty = true;
- Interlocked.Exchange(ref _lastContentWriteTicks, DateTimeOffset.UtcNow.UtcTicks);
+ Interlocked.Exchange(ref _lastContentWriteTicks, now.UtcTicks);
ContentAccessed?.Invoke(true);
}
@@ -1014,6 +1024,7 @@ private int SetFileSizeCore(FileNode node, ulong newSize, bool setAllocationSize
node.FileInfo.FileSize = newSize;
}
+ node.ContentVersion++;
return STATUS_SUCCESS;
}
diff --git a/src/ManagedDrive.Core/ManagedDrive.Core.csproj b/src/ManagedDrive.Core/ManagedDrive.Core.csproj
index a9e3289..055f11f 100644
--- a/src/ManagedDrive.Core/ManagedDrive.Core.csproj
+++ b/src/ManagedDrive.Core/ManagedDrive.Core.csproj
@@ -4,6 +4,8 @@
+
+
diff --git a/src/ManagedDrive.Core/Mounting/DiskOptions.cs b/src/ManagedDrive.Core/Mounting/DiskOptions.cs
index 2f71f5a..138ed65 100644
--- a/src/ManagedDrive.Core/Mounting/DiskOptions.cs
+++ b/src/ManagedDrive.Core/Mounting/DiskOptions.cs
@@ -26,6 +26,41 @@ public enum ImageCompressionLevel
SmallestSize = 3,
}
+///
+/// Conversion helpers for , shared by every writer that hands
+/// it off to a compression stream (DiskImageSerializer for image saves, SnapshotStore
+/// for snapshot blobs).
+///
+internal static class ImageCompressionLevelExtensions
+{
+ ///
+ /// Maps to the corresponding , used only
+ /// when reading legacy gzip-compressed images/blobs (nothing writes gzip anymore). Callers are
+ /// expected to have already checked and skipped
+ /// compression entirely in that case; it otherwise falls back to .
+ ///
+ public static System.IO.Compression.CompressionLevel ToDotNetCompressionLevel(this ImageCompressionLevel level) => level switch
+ {
+ ImageCompressionLevel.Fastest => System.IO.Compression.CompressionLevel.Fastest,
+ ImageCompressionLevel.SmallestSize => System.IO.Compression.CompressionLevel.SmallestSize,
+ _ => System.IO.Compression.CompressionLevel.Optimal,
+ };
+
+ ///
+ /// Maps to the corresponding Zstd compression level (1-22 range), used for all newly written
+ /// images/blobs. Callers are expected to have already checked
+ /// and skipped compression entirely in that case. (typically
+ /// ) overrides the preset mapping when set, letting
+ /// advanced users pick an exact Zstd level instead of one of the three coarse presets.
+ ///
+ public static int ToZstdLevel(this ImageCompressionLevel level, int? customLevel = null) => customLevel ?? level switch
+ {
+ ImageCompressionLevel.Fastest => 1,
+ ImageCompressionLevel.SmallestSize => 19,
+ _ => 3,
+ };
+}
+
///
/// Immutable configuration record used to create and mount a RAM disk.
///
@@ -106,6 +141,14 @@ public uint? AutoSaveIntervalMinutes
///
public ImageCompressionLevel CompressionLevel { get; init; } = ImageCompressionLevel.Fastest;
+ ///
+ /// Optional advanced override of the exact Zstd compression level (1-22) used instead of the
+ /// preset mapped from . null (the default) means use the
+ /// preset's level. Has no effect when is
+ /// — that always skips compression regardless.
+ ///
+ public int? CustomZstdLevel { get; init; }
+
///
/// Optional maximum number of timestamped snapshot images to retain alongside
/// . null disables count-based snapshot pruning.
diff --git a/src/ManagedDrive.Core/Mounting/MountOptionsFactory.cs b/src/ManagedDrive.Core/Mounting/MountOptionsFactory.cs
index 10f50df..63d98d7 100644
--- a/src/ManagedDrive.Core/Mounting/MountOptionsFactory.cs
+++ b/src/ManagedDrive.Core/Mounting/MountOptionsFactory.cs
@@ -20,6 +20,9 @@ public sealed record MountOverrides
/// Overrides the compression level when non-null.
public ImageCompressionLevel? CompressionLevel { get; init; }
+ /// Overrides the custom Zstd compression level (1-22) when non-null.
+ public int? CustomZstdLevel { get; init; }
+
/// Overrides the maximum snapshot count when non-null.
public uint? MaxSnapshotCount { get; init; }
@@ -83,6 +86,7 @@ public static DiskOptions BuildImageOptions(
AutoMount = overrides.AutoMount ?? baseOptions.AutoMount,
AutoSaveIntervalMinutes = overrides.AutoSaveIntervalMinutes ?? baseOptions.AutoSaveIntervalMinutes,
CompressionLevel = overrides.CompressionLevel ?? baseOptions.CompressionLevel,
+ CustomZstdLevel = overrides.CustomZstdLevel ?? baseOptions.CustomZstdLevel,
MaxSnapshotCount = overrides.MaxSnapshotCount ?? baseOptions.MaxSnapshotCount,
MaxSnapshotSizeBytes = overrides.MaxSnapshotSizeBytes ?? baseOptions.MaxSnapshotSizeBytes,
HighUsageWarnPercent = overrides.HighUsageWarnPercent ?? baseOptions.HighUsageWarnPercent,
diff --git a/src/ManagedDrive.Core/Mounting/RamDisk.cs b/src/ManagedDrive.Core/Mounting/RamDisk.cs
index d7d60f3..a2da00c 100644
--- a/src/ManagedDrive.Core/Mounting/RamDisk.cs
+++ b/src/ManagedDrive.Core/Mounting/RamDisk.cs
@@ -1,5 +1,6 @@
using Fsp;
using System.Runtime.InteropServices;
+using ThrottledLogging;
namespace ManagedDrive.Core.Mounting;
@@ -39,7 +40,7 @@ private RamDisk(MemoryFileSystem fs, FileSystemHost host, DiskOptions options)
public event Action? ContentAccessed;
///
- /// Occurs whenever an image save or snapshot write fails, whether triggered manually,
+ /// Raised whenever an image save or snapshot write fails, whether triggered manually,
/// by the periodic auto-save timer, or by the final save on unmount/dispose. The
/// exception is also rethrown to the caller for saves that are awaited synchronously
/// (e.g. a manual save); this event exists so background failures that would otherwise
@@ -201,24 +202,11 @@ public static RamDisk Create(DiskOptions options, string? password = null, IProg
ArchiveNodeMapBuilder.PeekArchive(options.SourceArchivePath, out var totalBytes, out _);
var nodeMap = ArchiveNodeMapBuilder.BuildNodeMap(options.SourceArchivePath, (long)totalBytes, progress);
- var actualUsed = nodeMap.GetTotalAllocated();
- var capacity = ResolveEffectiveCapacity(options.CapacityBytes, actualUsed);
- if (capacity != options.CapacityBytes)
- {
- originalCapacity = options.CapacityBytes;
- }
+ var capacity = ResolveAndApplyCapacity(nodeMap, options.CapacityBytes, ref options, out originalCapacity);
// Archive-sourced disks are always read-only: none of the supported archive
// formats support writing changes back, regardless of what options.ReadOnly says.
fs = new(capacity, options.VolumeLabel, nodeMap, readOnly: true);
-
- if (originalCapacity.HasValue)
- {
- options = options with
- {
- CapacityBytes = capacity
- };
- }
}
else if (options.PersistImagePath != null &&
File.Exists(options.PersistImagePath))
@@ -235,22 +223,9 @@ public static RamDisk Create(DiskOptions options, string? password = null, IProg
var configuredCapacity = savedCapacity > 0 ? savedCapacity : options.CapacityBytes;
var label = string.IsNullOrEmpty(savedLabel) ? options.VolumeLabel : savedLabel;
- var actualUsed = nodeMap.GetTotalAllocated();
- var capacity = ResolveEffectiveCapacity(configuredCapacity, actualUsed);
- if (capacity != configuredCapacity)
- {
- originalCapacity = configuredCapacity;
- }
+ var capacity = ResolveAndApplyCapacity(nodeMap, configuredCapacity, ref options, out originalCapacity);
fs = new(capacity, label, nodeMap, options.ReadOnly);
-
- if (originalCapacity.HasValue)
- {
- options = options with
- {
- CapacityBytes = capacity
- };
- }
}
else
{
@@ -475,11 +450,14 @@ public void SaveToImage(IProgress? progress = null)
Options.PersistImagePath,
Options.CompressionLevel,
_password is not null && _cek is not null ? new ImageEncryptionInfo(_password, _cek) : null,
- progress);
+ progress,
+ Options.CustomZstdLevel);
}
catch (Exception ex)
{
- Logger.LogError(ex, "Failed to save disk image to {ImagePath}.", Options.PersistImagePath);
+ Logger.LogErrorThrottled(
+ $"save-failed:{Options.PersistImagePath}", TimeSpan.FromMinutes(10),
+ "Failed to save disk image to {ImagePath}: {Error}", Options.PersistImagePath, ex.Message);
SaveFailed?.Invoke(this, ex);
throw;
}
@@ -525,19 +503,44 @@ public void SaveToImageSafe()
/// so a manual save and a periodic auto-save never write/prune snapshots concurrently.
///
///
- /// Optional progress reporter, updated with a fraction in [0, 1] across both the image save
- /// (the first half of the range) and the snapshot write (the second half).
+ /// Optional progress reporter, updated with a fraction in [0, 1]. When snapshot retention is
+ /// configured (see /)
+ /// and a snapshot write may actually happen, this range is split across both the image save
+ /// (the first half) and the snapshot write (the second half); otherwise the full range is
+ /// given to the image save alone, so the bar doesn't stall at 50% when no snapshot work
+ /// follows it.
///
public void SaveToImageWithSnapshot(IProgress? progress = null)
{
lock (_autoSaveLock)
{
- SaveToImage(progress is null ? null : new Progress(p => progress.Report(p * 0.5)));
- TryWriteSnapshot(progress is null ? null : new Progress(p => progress.Report(0.5 + p * 0.5)));
+ // MappedProgress forwards straight into the caller's IProgress instead of
+ // wrapping it in a new Progress constructed on this background thread — that
+ // would capture a non-UI SynchronizationContext and route every tick through an extra,
+ // unordered ThreadPool hop before it ever reached the UI-bound progress object.
+ var mayWriteSnapshot = Options.PersistImagePath is not null
+ && (Options.MaxSnapshotCount is not null || Options.MaxSnapshotSizeBytes is not null);
+
+ if (mayWriteSnapshot)
+ {
+ SaveToImage(progress is null ? null : new MappedProgress(progress, 0.5, 0.0));
+ TryWriteSnapshot(progress is null ? null : new MappedProgress(progress, 0.5, 0.5));
+ }
+ else
+ {
+ SaveToImage(progress);
+ TryWriteSnapshot();
+ }
+
progress?.Report(1.0);
}
}
+ private sealed class MappedProgress(IProgress inner, double scale, double offset) : IProgress
+ {
+ public void Report(double value) => inner.Report(offset + (value * scale));
+ }
+
///
/// Sets, changes, or removes this disk's password. Passing a non-null value when the disk is
/// not yet encrypted generates a fresh content-encryption key (CEK); passing a non-null value
@@ -664,6 +667,31 @@ public bool TryRestoreFromSnapshot(string snapshotPath, out string? error)
internal static ulong ResolveEffectiveCapacity(ulong configuredCapacity, ulong actualUsed) =>
Math.Max(configuredCapacity, actualUsed);
+ ///
+ /// Resolves the effective capacity for a just-loaded against
+ /// via , and — if
+ /// that raised the capacity — updates in place to the new value and
+ /// reports the original in . Shared by 's
+ /// archive-import and image-load branches.
+ ///
+ private static ulong ResolveAndApplyCapacity(
+ FileNodeMap nodeMap, ulong configuredCapacity, ref DiskOptions options, out ulong? originalCapacity)
+ {
+ var actualUsed = nodeMap.GetTotalAllocated();
+ var capacity = ResolveEffectiveCapacity(configuredCapacity, actualUsed);
+ originalCapacity = capacity != configuredCapacity ? configuredCapacity : null;
+
+ if (originalCapacity.HasValue)
+ {
+ options = options with
+ {
+ CapacityBytes = capacity
+ };
+ }
+
+ return capacity;
+ }
+
private static void ConfigureHost(FileSystemHost host)
{
host.SectorSize = (ushort)FileNode.AllocationUnit;
@@ -680,7 +708,7 @@ private static void ConfigureHost(FileSystemHost host)
// entirely, shrinking WinFsp's per-handle overhead. Matches the official WinFsp memfs sample.
host.PostCleanupWhenModifiedOnly = true;
host.VolumeCreationTime = (ulong)DateTimeOffset.UtcNow.ToFileTime();
- host.VolumeSerialNumber = (uint)new Random().Next(int.MaxValue / 2);
+ host.VolumeSerialNumber = (uint)Random.Shared.Next(int.MaxValue / 2);
}
///
@@ -907,7 +935,8 @@ private void TryWriteSnapshot(IProgress? progress = null)
DateTimeOffset.UtcNow,
Options.CompressionLevel,
_cek,
- progress);
+ progress,
+ Options.CustomZstdLevel);
SnapshotManager.Prune(path, Options.MaxSnapshotCount, Options.MaxSnapshotSizeBytes);
}
diff --git a/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs b/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs
index 26dfefa..47f5531 100644
--- a/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs
+++ b/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs
@@ -20,7 +20,7 @@ namespace ManagedDrive.Core.Persistence;
/// Image format (little-endian binary):
///
/// - 4-byte magic "MDRD"
-/// - Int32 version (currently 4)
+/// - Int32 version (currently 5)
/// - Byte holding an value (version 2+ only; absent in version 1, which is always uncompressed)
/// - Byte IsEncrypted (version 3+ only; absent/false in earlier versions)
/// - UInt64 capacity in bytes (always plaintext, so callers can preview it without a password)
@@ -39,25 +39,54 @@ namespace ManagedDrive.Core.Persistence;
/// managed-array limits at roughly 2 GB — kept only so old images keep loading.
///
/// -
-/// Version 4 (current) when encrypted: a random 12-byte base nonce, then a sequence of
+/// Version 4+ when encrypted: a random 12-byte base nonce, then a sequence of
/// chunks, each independently AES-256-GCM encrypted so no single buffer needs to hold the
/// whole node region. Each chunk is [Int32 ciphertext length][16-byte tag][ciphertext
/// bytes], terminated by a zero-length chunk. Per-chunk nonces are derived from the base
/// nonce by XOR-ing its last 4 bytes with the big-endian chunk index, guaranteeing a unique
/// nonce per chunk under the same key/base nonce (see ).
+/// Identical layout for both version 4 and 5 — only the compression algorithm wrapped inside
+/// differs.
+///
+/// - When not encrypted (any version): the node region follows directly, compressed whenever the level is not , streamed straight from/to the file rather than buffered.
+/// -
+/// Compression algorithm is determined entirely by the file's version, not by any separate
+/// field: versions 1-4 use gzip/deflate (), read-only — nothing writes
+/// gzip anymore. Version 5 (current) uses Zstd () for both writing and
+/// reading, which compresses substantially faster than gzip at a comparable ratio.
+///
+/// -
+/// Version 5's Zstd-compressed node region (whether encrypted or not) is itself a sequence of
+/// independently compressed chunks framed as [Int32 compressed length][compressed bytes],
+/// terminated by a zero-length chunk — mirroring 's chunk framing. This
+/// lets both and
+/// compress/decompress chunks concurrently across a worker pool while still writing/reading
+/// them in original order, since chunk boundaries are known up front rather than requiring a
+/// full decompress pass to discover. Encrypted images wrap this chunk sequence in
+/// 's own (independently sized) chunking, so the two chunk boundaries
+/// do not line up — that's fine, since Zstd's chunk framing is entirely self-describing.
///
-/// - When not encrypted (any version): the node region follows directly, gzip-compressed whenever the level is not , streamed straight from/to the file rather than buffered.
/// - Node region contents: Int32 node count, then for each node: path, metadata, security descriptor bytes, file data bytes
///
///
public static class DiskImageSerializer
{
private const int CekSize = 32;
+
+ ///
+ /// Buffer size for the image file's . Save/Load are purely sequential,
+ /// large-volume I/O, so a larger-than-default (4 KB) buffer cuts the number of read/write
+ /// syscalls substantially — this matters most for uncompressed images, where node metadata is
+ /// written directly to this stream in many small calls rather than
+ /// through a buffering .
+ ///
+ private const int FileStreamBufferSize = 1024 * 1024;
+
private const int NonceSize = 12;
private const int Pbkdf2Iterations = 210_000;
private const int SaltSize = 16;
private const int TagSize = 16;
- private const int Version = 4;
+ private const int Version = 5;
private static readonly byte[] Magic = "MDRD"u8.ToArray();
///
@@ -109,7 +138,13 @@ public static FileNodeMap Load(
out byte[]? cek,
IProgress? progress = null)
{
- using var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read);
+ using var stream = new FileStream(imagePath, new FileStreamOptions
+ {
+ Mode = FileMode.Open,
+ Access = FileAccess.Read,
+ BufferSize = FileStreamBufferSize,
+ Options = FileOptions.SequentialScan,
+ });
using var reader = new BinaryReader(stream, System.Text.Encoding.UTF8, leaveOpen: false);
ReadHeader(reader, out var version, out var level, out var isEncrypted);
@@ -143,7 +178,13 @@ public static void PeekHeader(
out string volumeLabel,
out bool isEncrypted)
{
- using var stream = new FileStream(imagePath, FileMode.Open, FileAccess.Read);
+ using var stream = new FileStream(imagePath, new FileStreamOptions
+ {
+ Mode = FileMode.Open,
+ Access = FileAccess.Read,
+ BufferSize = FileStreamBufferSize,
+ Options = FileOptions.SequentialScan,
+ });
using var reader = new BinaryReader(stream, System.Text.Encoding.UTF8, leaveOpen: false);
ReadHeader(reader, out var version, out var level, out isEncrypted);
@@ -151,11 +192,7 @@ public static void PeekHeader(
if (version <= 2)
{
// Legacy layout: capacity/label are inside the optionally compressed payload.
- var compressed = version == 2 && level != ImageCompressionLevel.None;
- using var payloadReader = compressed
- ? new BinaryReader(new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true), System.Text.Encoding.UTF8)
- : reader;
-
+ using var payloadReader = OpenLegacyPayloadReader(stream, reader, version, level);
capacityBytes = payloadReader.ReadUInt64();
volumeLabel = payloadReader.ReadString();
}
@@ -187,6 +224,13 @@ public static void PeekHeader(
/// The subsequent gzip compression and (when encrypting) AES-256-GCM chunk encryption happen
/// as nodes stream through and are not individually reported.
///
+ ///
+ /// Optional advanced override (1-22) of the exact Zstd level used instead of the one mapped
+ /// from (see ).
+ /// Purely an encoder-side speed/ratio choice — not persisted in the image itself, since Zstd
+ /// decompression needs no level parameter, so works identically regardless
+ /// of what level a given image was originally written with.
+ ///
public static void Save(
FileNodeMap nodeMap,
ulong capacityBytes,
@@ -194,7 +238,8 @@ public static void Save(
string imagePath,
ImageCompressionLevel level,
ImageEncryptionInfo? encryption = null,
- IProgress? progress = null)
+ IProgress? progress = null,
+ int? customZstdLevel = null)
{
var compress = level != ImageCompressionLevel.None;
var directory = Path.GetDirectoryName(imagePath);
@@ -210,7 +255,13 @@ public static void Save(
try
{
- using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write))
+ using (var stream = new FileStream(tempPath, new FileStreamOptions
+ {
+ Mode = FileMode.Create,
+ Access = FileAccess.Write,
+ BufferSize = FileStreamBufferSize,
+ Options = FileOptions.SequentialScan,
+ }))
{
using (var writer = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true))
{
@@ -239,13 +290,13 @@ public static void Save(
// Node data streams straight into chunked AES-GCM encryption below —
// never buffered whole, so there is no ~2 GB ceiling on disk content.
using var chunkedStream = new ChunkedGcm.WriteStream(stream, enc.Cek, baseNonce, ChunkedGcm.ChunkSize);
- WriteNodeRegion(chunkedStream, compress, level, nodeMap, progress);
+ WriteNodeRegion(chunkedStream, compress, level, customZstdLevel, nodeMap, progress);
chunkedStream.Complete();
}
else
{
writer.Flush();
- WriteNodeRegion(stream, compress, level, nodeMap, progress);
+ WriteNodeRegion(stream, compress, level, customZstdLevel, nodeMap, progress);
}
}
@@ -271,23 +322,25 @@ public static void Save(
///
/// Writes the node-count-prefixed node region for directly into
- /// , gzip-compressing on the fly when is
- /// set. Never materializes the whole region as a single in-memory buffer, so disk content of
- /// any size can be saved regardless of the ~2 GB limit on /managed
- /// arrays. The (when used) is explicitly disposed here — rather than
+ /// , Zstd-compressing on the fly (in parallel across chunks, see
+ /// ) when is set. Never materializes the
+ /// whole region as a single in-memory buffer, so disk content of any size can be saved
+ /// regardless of the ~2 GB limit on /managed arrays. The
+ /// (when used) is explicitly disposed here — rather than
/// relying on 's own disposal with leaveOpen: true, which
- /// would skip it — so the deflate stream's final block/trailer is always flushed before
+ /// would skip it — so every outstanding chunk is compressed and flushed before
/// is used for anything else.
///
private static void WriteNodeRegion(
Stream target,
bool compress,
ImageCompressionLevel level,
+ int? customZstdLevel,
FileNodeMap nodeMap,
IProgress? progress)
{
var payloadStream = compress
- ? new GZipStream(target, ToCompressionLevel(level), leaveOpen: true)
+ ? new ParallelZstd.WriteStream(target, level.ToZstdLevel(customZstdLevel))
: target;
try
@@ -351,18 +404,27 @@ private static FileNodeMap LoadLegacy(
out string volumeLabel,
Action? reportTick = null)
{
- var compressed = version == 2 && level != ImageCompressionLevel.None;
-
- using var payloadReader = compressed
- ? new BinaryReader(new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true), System.Text.Encoding.UTF8)
- : reader;
-
+ using var payloadReader = OpenLegacyPayloadReader(stream, reader, version, level);
capacityBytes = payloadReader.ReadUInt64();
volumeLabel = payloadReader.ReadString();
return ReadNodes(payloadReader, reportTick);
}
+ ///
+ /// Opens the reader over a version 1/2 image's single payload region — gzip-decompressing it
+ /// first when the image is a compressed version 2 (version 1 is never compressed). Shared by
+ /// (reads capacity/label only) and (reads
+ /// capacity/label, then the full node region) so both stay in sync on this legacy layout rule.
+ ///
+ private static BinaryReader OpenLegacyPayloadReader(FileStream stream, BinaryReader reader, int version, ImageCompressionLevel level)
+ {
+ var compressed = version == 2 && level != ImageCompressionLevel.None;
+ return compressed
+ ? new BinaryReader(new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true), System.Text.Encoding.UTF8)
+ : reader;
+ }
+
///
/// Reads a version 3 or 4 image: capacity/label are always plaintext header fields; the node
/// region (from node count onward) is compressed and, when encrypted, additionally wrapped in
@@ -388,12 +450,14 @@ private static FileNodeMap LoadCurrent(
cek = null;
var compressed = level != ImageCompressionLevel.None;
+ var useZstd = version >= 5;
+
if (!isEncrypted)
{
// The node region is the last thing in the file for an unencrypted image, so
// decompressing straight off the file stream (rather than buffering it) is safe —
- // GZipStream simply reads until end of file.
- return ReadNodeRegion(stream, compressed, reportTick);
+ // the decompression stream simply reads until end of file.
+ return ReadNodeRegion(stream, compressed, useZstd, reportTick);
}
if (password is null)
@@ -413,7 +477,7 @@ private static FileNodeMap LoadCurrent(
return version switch
{
3 => LoadLegacyEncryptedBlob(stream, reader, resolvedCek, compressed, reportTick),
- 4 => LoadChunkedEncrypted(stream, reader, resolvedCek, compressed, reportTick),
+ 4 or 5 => LoadChunkedEncrypted(stream, reader, resolvedCek, compressed, useZstd, reportTick),
_ => throw new InvalidDataException($"Unsupported image version: {version}."),
};
}
@@ -452,7 +516,7 @@ private static FileNodeMap LoadLegacyEncryptedBlob(
// already at (or near) end-of-file here — reportTick will jump close to 1.0 on the
// first node and stay there for the rest of this legacy (version 3) path.
using var nodeRegionStream = new MemoryStream(plaintext, writable: false);
- return ReadNodeRegion(nodeRegionStream, compressed, reportTick);
+ return ReadNodeRegion(nodeRegionStream, compressed, useZstd: false, reportTick);
}
finally
{
@@ -461,15 +525,17 @@ private static FileNodeMap LoadLegacyEncryptedBlob(
}
///
- /// Version 4's chunked encrypted node region: each chunk was independently AES-256-GCM
+ /// Version 4/5's chunked encrypted node region: each chunk was independently AES-256-GCM
/// encrypted on save, so decryption streams chunk-by-chunk via
- /// rather than requiring the whole region in memory at once.
+ /// rather than requiring the whole region in memory at once.
+ /// distinguishes the compression algorithm wrapped inside (version 4 = gzip, version 5 = Zstd).
///
private static FileNodeMap LoadChunkedEncrypted(
FileStream stream,
BinaryReader reader,
byte[] cek,
bool compressed,
+ bool useZstd,
Action? reportTick = null)
{
var baseNonce = reader.ReadBytes(NonceSize);
@@ -477,7 +543,7 @@ private static FileNodeMap LoadChunkedEncrypted(
try
{
using var chunkedStream = new ChunkedGcm.ReadStream(stream, cek, baseNonce);
- return ReadNodeRegion(chunkedStream, compressed, reportTick);
+ return ReadNodeRegion(chunkedStream, compressed, useZstd, reportTick);
}
catch (CryptographicException)
{
@@ -487,13 +553,17 @@ private static FileNodeMap LoadChunkedEncrypted(
///
/// Reads the node-count-prefixed node region from , transparently
- /// gzip-decompressing when is set. Mirrors .
+ /// decompressing when is set — via Zstd when
+ /// is set (version 5, current), otherwise via gzip (versions 1-4,
+ /// read-only). Mirrors .
///
- private static FileNodeMap ReadNodeRegion(Stream source, bool compressed, Action? reportTick = null)
+ private static FileNodeMap ReadNodeRegion(Stream source, bool compressed, bool useZstd, Action? reportTick = null)
{
using var payloadReader = new BinaryReader(
compressed
- ? new GZipStream(source, CompressionMode.Decompress, leaveOpen: true)
+ ? useZstd
+ ? new ParallelZstd.ReadStream(source)
+ : new GZipStream(source, CompressionMode.Decompress, leaveOpen: true)
: source,
System.Text.Encoding.UTF8,
leaveOpen: true);
@@ -514,7 +584,7 @@ private static void ReadHeader(
}
version = reader.ReadInt32();
- if (version is not (1 or 2 or 3 or 4))
+ if (version is not (1 or 2 or 3 or 4 or 5))
{
throw new InvalidDataException($"Unsupported image version: {version}.");
}
@@ -525,30 +595,15 @@ private static void ReadHeader(
private static (string Path, FileNode Node) ReadNode(BinaryReader reader)
{
- var path = reader.ReadString();
+ var metadata = NodeMetadataIO.ReadMetadata(reader);
+ var path = metadata.Path;
var node = new FileNode
{
- FileInfo =
- {
- FileAttributes = reader.ReadUInt32(),
- AllocationSize = reader.ReadUInt64(),
- FileSize = reader.ReadUInt64(),
- CreationTime = reader.ReadUInt64(),
- LastAccessTime = reader.ReadUInt64(),
- LastWriteTime = reader.ReadUInt64(),
- ChangeTime = reader.ReadUInt64(),
- IndexNumber = reader.ReadUInt64(),
- HardLinks = reader.ReadUInt32(),
- },
+ FileInfo = metadata.FileInfo,
+ FileSecurity = metadata.Security,
};
- var secLen = reader.ReadInt32();
- if (secLen > 0)
- {
- node.FileSecurity = reader.ReadBytes(secLen);
- }
-
var dataLen = reader.ReadInt64();
if (dataLen > 0 && !node.IsDirectory)
{
@@ -580,13 +635,6 @@ private static FileNodeMap ReadNodes(BinaryReader payloadReader, Action? reportT
return nodeMap;
}
- private static CompressionLevel ToCompressionLevel(ImageCompressionLevel level) => level switch
- {
- ImageCompressionLevel.Fastest => CompressionLevel.Fastest,
- ImageCompressionLevel.SmallestSize => CompressionLevel.SmallestSize,
- _ => CompressionLevel.Optimal,
- };
-
private static byte[] UnwrapCek(
byte[] wrappedCek,
string password,
@@ -647,20 +695,7 @@ private static byte[] WrapCek(
private static void WriteNode(BinaryWriter writer, string path, FileNode node)
{
- writer.Write(path);
- writer.Write(node.FileInfo.FileAttributes);
- writer.Write(node.FileInfo.AllocationSize);
- writer.Write(node.FileInfo.FileSize);
- writer.Write(node.FileInfo.CreationTime);
- writer.Write(node.FileInfo.LastAccessTime);
- writer.Write(node.FileInfo.LastWriteTime);
- writer.Write(node.FileInfo.ChangeTime);
- writer.Write(node.FileInfo.IndexNumber);
- writer.Write(node.FileInfo.HardLinks);
-
- var security = node.FileSecurity ?? [];
- writer.Write(security.Length);
- writer.Write(security);
+ NodeMetadataIO.WriteMetadata(writer, path, node);
if (node is { IsDirectory: false, FileData: not null, FileInfo.FileSize: > 0 })
{
diff --git a/src/ManagedDrive.Core/Persistence/NodeMetadataIO.cs b/src/ManagedDrive.Core/Persistence/NodeMetadataIO.cs
new file mode 100644
index 0000000..9791f20
--- /dev/null
+++ b/src/ManagedDrive.Core/Persistence/NodeMetadataIO.cs
@@ -0,0 +1,55 @@
+namespace ManagedDrive.Core.Persistence;
+
+///
+/// Shared binary read/write logic for the path + + security
+/// descriptor portion of a node record. Extracted because and
+/// write this exact layout byte-for-byte identically before
+/// diverging on how the file's data is stored (inline bytes vs. a content-addressed blob hash).
+/// Do not extend this beyond metadata into data/blob territory - the two formats are fundamentally
+/// different there.
+///
+internal static class NodeMetadataIO
+{
+ public readonly record struct NodeMetadata(string Path, Fsp.Interop.FileInfo FileInfo, byte[]? Security);
+
+ public static void WriteMetadata(BinaryWriter writer, string path, FileNode node)
+ {
+ writer.Write(path);
+ writer.Write(node.FileInfo.FileAttributes);
+ writer.Write(node.FileInfo.AllocationSize);
+ writer.Write(node.FileInfo.FileSize);
+ writer.Write(node.FileInfo.CreationTime);
+ writer.Write(node.FileInfo.LastAccessTime);
+ writer.Write(node.FileInfo.LastWriteTime);
+ writer.Write(node.FileInfo.ChangeTime);
+ writer.Write(node.FileInfo.IndexNumber);
+ writer.Write(node.FileInfo.HardLinks);
+
+ var security = node.FileSecurity ?? [];
+ writer.Write(security.Length);
+ writer.Write(security);
+ }
+
+ public static NodeMetadata ReadMetadata(BinaryReader reader)
+ {
+ var path = reader.ReadString();
+
+ var fileInfo = new Fsp.Interop.FileInfo
+ {
+ FileAttributes = reader.ReadUInt32(),
+ AllocationSize = reader.ReadUInt64(),
+ FileSize = reader.ReadUInt64(),
+ CreationTime = reader.ReadUInt64(),
+ LastAccessTime = reader.ReadUInt64(),
+ LastWriteTime = reader.ReadUInt64(),
+ ChangeTime = reader.ReadUInt64(),
+ IndexNumber = reader.ReadUInt64(),
+ HardLinks = reader.ReadUInt32(),
+ };
+
+ var secLen = reader.ReadInt32();
+ var security = secLen > 0 ? reader.ReadBytes(secLen) : null;
+
+ return new NodeMetadata(path, fileInfo, security);
+ }
+}
diff --git a/src/ManagedDrive.Core/Persistence/ParallelZstd.cs b/src/ManagedDrive.Core/Persistence/ParallelZstd.cs
new file mode 100644
index 0000000..d633d2a
--- /dev/null
+++ b/src/ManagedDrive.Core/Persistence/ParallelZstd.cs
@@ -0,0 +1,292 @@
+using System.Buffers.Binary;
+
+namespace ManagedDrive.Core.Persistence;
+
+///
+/// Write/read helper pair that compresses/decompresses a stream of bytes as a sequence of
+/// independently compressed Zstd chunks, each framed as [Int32 compressed length][compressed
+/// bytes] and terminated by a zero-length chunk (mirroring 's framing),
+/// processed concurrently across a bounded worker pool while still emitting/consuming them in
+/// original order. This turns both image/snapshot save and load (otherwise single-threaded
+/// bottlenecks with plain /)
+/// into parallelizable operations. The explicit length framing (rather than relying on
+/// concatenated-frame auto-detection, as a plain would
+/// need to scan for) is what lets dispatch each chunk's decompression to
+/// the thread pool without first decompressing anything to find chunk boundaries.
+/// Used by (the node region of a .mdr image) and
+/// (individual content-addressed file blobs).
+///
+internal static class ParallelZstd
+{
+ ///
+ /// Test-only override for ; means use the
+ /// production default. Set via InternalsVisibleTo("ManagedDrive.Tests").
+ ///
+ internal static int? TestChunkSizeOverride;
+
+ ///
+ /// Size of each independently compressed chunk. Large enough that per-chunk compression
+ /// overhead (frame header/epilogue, a fresh context) stays
+ /// negligible relative to the data compressed, but small enough to get real parallelism on
+ /// typical disk-image sizes. Overridable by tests via to
+ /// exercise the multi-chunk path without allocating real multi-megabyte buffers.
+ ///
+ private const int DefaultChunkSize = 4 * 1024 * 1024;
+
+ internal static int ChunkSize => TestChunkSizeOverride ?? DefaultChunkSize;
+
+ ///
+ /// Read-only counterpart to : reads the
+ /// [length][compressed bytes] chunk sequence written by it, decompressing chunks on a
+ /// bounded worker pool while yielding decompressed bytes in original order. Chunk headers are
+ /// read sequentially off (negligible cost), and up to
+ /// chunks are kept in flight at once so decompression
+ /// of later chunks overlaps with the caller consuming earlier ones — the same prefetch pattern
+ /// uses for compression, mirrored for the read side.
+ ///
+ internal sealed class ReadStream(Stream source, int? maxDegreeOfParallelism = null) : Stream
+ {
+ private readonly int _maxDegreeOfParallelism = Math.Max(1, maxDegreeOfParallelism ?? Environment.ProcessorCount);
+ private readonly Queue> _pending = new();
+ private byte[] _currentChunk = [];
+ private bool _endOfStream;
+ private int _positionInChunk;
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => false;
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush() => throw new NotSupportedException();
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ var totalRead = 0;
+
+ while (count > 0)
+ {
+ if (_positionInChunk == _currentChunk.Length)
+ {
+ // Not gated on _endOfStream alone: that flag only means "no more chunk
+ // headers left to read," but chunks already prefetched into _pending (read
+ // ahead of the terminator) still need to be drained.
+ if (!TryAdvanceChunk())
+ {
+ break;
+ }
+ }
+
+ var toCopy = Math.Min(count, _currentChunk.Length - _positionInChunk);
+ Array.Copy(_currentChunk, _positionInChunk, buffer, offset, toCopy);
+ _positionInChunk += toCopy;
+ offset += toCopy;
+ count -= toCopy;
+ totalRead += toCopy;
+ }
+
+ return totalRead;
+ }
+
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+
+ private static byte[] Decompress(byte[] compressed)
+ {
+ using var decompressor = new ZstdSharp.Decompressor();
+ return decompressor.Unwrap(compressed).ToArray();
+ }
+
+ private void FillPending()
+ {
+ while (!_endOfStream && _pending.Count < _maxDegreeOfParallelism)
+ {
+ if (!TryQueueNextChunk())
+ {
+ break;
+ }
+ }
+ }
+
+ private bool TryAdvanceChunk()
+ {
+ FillPending();
+
+ if (_pending.Count == 0)
+ {
+ _endOfStream = true;
+ return false;
+ }
+
+ _currentChunk = _pending.Dequeue().GetAwaiter().GetResult();
+ _positionInChunk = 0;
+
+ // Immediately queue the next chunk so decompression of what's now the tail of the
+ // pending queue overlaps with the caller consuming _currentChunk.
+ FillPending();
+
+ return _currentChunk.Length > 0 || TryAdvanceChunk();
+ }
+
+ private bool TryQueueNextChunk()
+ {
+ if (_endOfStream)
+ {
+ return false;
+ }
+
+ Span lengthBytes = stackalloc byte[4];
+ source.ReadExactly(lengthBytes);
+ var length = BinaryPrimitives.ReadInt32LittleEndian(lengthBytes);
+
+ if (length == 0)
+ {
+ _endOfStream = true;
+ return false;
+ }
+
+ var chunk = new byte[length];
+ source.ReadExactly(chunk);
+
+ _pending.Enqueue(Task.Run(() => Decompress(chunk)));
+ return true;
+ }
+ }
+
+ ///
+ /// Write-only that buffers up to bytes at a time
+ /// and, on each full buffer plus once more on , hands that chunk to a
+ /// background that Zstd-compresses it independently. Compression runs
+ /// concurrently (bounded by ), but chunks are always
+ /// written to in the order they were queued, blocking on the oldest
+ /// outstanding task if the queue is full — so output ordering matches input ordering
+ /// regardless of which task happens to finish first.
+ ///
+ internal sealed class WriteStream(Stream target, int level, int? maxDegreeOfParallelism = null) : Stream
+ {
+ private readonly int _maxDegreeOfParallelism = Math.Max(1, maxDegreeOfParallelism ?? Environment.ProcessorCount);
+ private readonly Queue> _pending = new();
+ private byte[] _buffer = new byte[ChunkSize];
+ private int _bufferLength;
+ private bool _completed;
+
+ public override bool CanRead => false;
+ public override bool CanSeek => false;
+ public override bool CanWrite => true;
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ ///
+ /// Flushes any remaining buffered bytes as a final chunk, then waits for all pending
+ ///
+ public void Complete()
+ {
+ if (_completed)
+ {
+ return;
+ }
+
+ if (_bufferLength > 0)
+ {
+ QueueChunk();
+ }
+
+ while (_pending.Count > 0)
+ {
+ DrainOne();
+ }
+
+ WriteChunkHeader(0);
+ _completed = true;
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
+
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ while (count > 0)
+ {
+ var toCopy = Math.Min(count, _buffer.Length - _bufferLength);
+ Array.Copy(buffer, offset, _buffer, _bufferLength, toCopy);
+ _bufferLength += toCopy;
+ offset += toCopy;
+ count -= toCopy;
+
+ if (_bufferLength == _buffer.Length)
+ {
+ FlushChunk();
+ }
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && !_completed)
+ {
+ Complete();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ private static byte[] Compress(byte[] data, int length, int level)
+ {
+ using var compressor = new ZstdSharp.Compressor(level);
+ return compressor.Wrap(data.AsSpan(0, length)).ToArray();
+ }
+
+ private void DrainOne()
+ {
+ var compressed = _pending.Dequeue().GetAwaiter().GetResult();
+ WriteChunkHeader(compressed.Length);
+ target.Write(compressed, 0, compressed.Length);
+ }
+
+ private void FlushChunk()
+ {
+ if (_pending.Count >= _maxDegreeOfParallelism)
+ {
+ DrainOne();
+ }
+
+ QueueChunk();
+ }
+
+ private void QueueChunk()
+ {
+ var chunk = _buffer;
+ var length = _bufferLength;
+ _buffer = new byte[ChunkSize];
+ _bufferLength = 0;
+
+ _pending.Enqueue(Task.Run(() => Compress(chunk, length, level)));
+ }
+
+ private void WriteChunkHeader(int length)
+ {
+ Span lengthBytes = stackalloc byte[4];
+ BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, length);
+ target.Write(lengthBytes);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs b/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs
index 979259b..35c6152 100644
--- a/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs
+++ b/src/ManagedDrive.Core/Snapshots/SnapshotManager.cs
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using System.Text.RegularExpressions;
+using ThrottledLogging;
namespace ManagedDrive.Core.Snapshots;
@@ -305,11 +306,15 @@ public static void Prune(string mainImagePath, uint? maxCount, ulong? maxTotalBy
catch (IOException ex)
{
// Best-effort pruning; leave this file for the next attempt.
- Logger.LogWarning(ex, "Failed to prune snapshot '{Path}'", snapshot.Path);
+ Logger.LogWarningThrottled(
+ $"prune-failed:{snapshot.Path}", TimeSpan.FromMinutes(10),
+ "Failed to prune snapshot '{Path}': {Error}", snapshot.Path, ex.Message);
}
catch (UnauthorizedAccessException ex)
{
- Logger.LogWarning(ex, "Failed to prune snapshot '{Path}'", snapshot.Path);
+ Logger.LogWarningThrottled(
+ $"prune-failed:{snapshot.Path}", TimeSpan.FromMinutes(10),
+ "Failed to prune snapshot '{Path}': {Error}", snapshot.Path, ex.Message);
}
index++;
@@ -331,10 +336,11 @@ public static void WriteSnapshot(
DateTimeOffset timestampUtc,
ImageCompressionLevel level,
byte[]? cek = null,
- IProgress? progress = null)
+ IProgress? progress = null,
+ int? customZstdLevel = null)
{
var indexPath = BuildSnapshotPath(mainImagePath, timestampUtc);
- SnapshotStore.Write(nodeMap, capacityBytes, volumeLabel, indexPath, BlobDirectory(mainImagePath), level, cek, progress);
+ SnapshotStore.Write(nodeMap, capacityBytes, volumeLabel, indexPath, BlobDirectory(mainImagePath), level, cek, progress, customZstdLevel);
}
private static string BlobDirectory(string mainImagePath) => SnapshotStore.ComputeBlobDirectory(mainImagePath);
@@ -352,12 +358,27 @@ private static string BlobDirectoryFromSnapshotPath(string indexPath)
return Path.Combine(directory, baseName + ".snapblobs");
}
+ ///
+ /// Computes (or reuses) the SHA-256 hash of 's current content.
+ /// Cached on the node against , which is bumped only
+ /// when content actually changes — this avoids rehashing every file on every dirty
+ /// auto-save tick when only a subset of files changed since the last snapshot.
+ ///
private static byte[] ComputeHash(FileNode node)
{
+ if (node.CachedContentHash is { } cached && node.CachedContentHashVersion == node.ContentVersion)
+ {
+ return cached;
+ }
+
var fileSize = (int)Math.Min(node.FileInfo.FileSize, (ulong)node.FileData!.Length);
using var incrementalHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
node.FileData.HashInto(incrementalHash, fileSize);
- return incrementalHash.GetHashAndReset();
+ var hash = incrementalHash.GetHashAndReset();
+
+ node.CachedContentHash = hash;
+ node.CachedContentHashVersion = node.ContentVersion;
+ return hash;
}
///
@@ -395,11 +416,15 @@ private static void GarbageCollectBlobs(string mainImagePath)
}
catch (IOException ex)
{
- Logger.LogWarning(ex, "Failed to delete unreferenced blob '{Path}'", blobPath);
+ Logger.LogWarningThrottled(
+ $"blob-gc-failed:{blobPath}", TimeSpan.FromMinutes(10),
+ "Failed to delete unreferenced blob '{Path}': {Error}", blobPath, ex.Message);
}
catch (UnauthorizedAccessException ex)
{
- Logger.LogWarning(ex, "Failed to delete unreferenced blob '{Path}'", blobPath);
+ Logger.LogWarningThrottled(
+ $"blob-gc-failed:{blobPath}", TimeSpan.FromMinutes(10),
+ "Failed to delete unreferenced blob '{Path}': {Error}", blobPath, ex.Message);
}
}
}
diff --git a/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs b/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs
index 1fc0994..8483342 100644
--- a/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs
+++ b/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs
@@ -24,6 +24,14 @@ internal static class SnapshotStore
///
private const int BlobFlagChunked = 0b100;
+ ///
+ /// Marks a blob's compressed payload as Zstd rather than gzip. Only meaningful when
+ /// is also set. Never set for blobs written before Zstd
+ /// support existed (they stay gzip forever, since content-addressed blobs are never rewritten
+ /// once they exist), so old blobs keep loading via the gzip branch in .
+ ///
+ private const int BlobFlagZstd = 0b1000;
+
private const int BlobNonceSize = 12;
private const int BlobTagSize = 16;
private const int Version = 1;
@@ -190,7 +198,8 @@ internal static void Write(
string blobDirectory,
ImageCompressionLevel level,
byte[]? cek,
- IProgress? progress = null)
+ IProgress? progress = null,
+ int? customZstdLevel = null)
{
Directory.CreateDirectory(blobDirectory);
@@ -221,7 +230,7 @@ internal static void Write(
var written = 0;
foreach (var kvp in nodes)
{
- WriteNode(writer, kvp.Key, kvp.Value, blobDirectory, level, cek);
+ WriteNode(writer, kvp.Key, kvp.Value, blobDirectory, level, cek, customZstdLevel);
written++;
progress?.Report((double)written / nodes.Count);
}
@@ -252,14 +261,17 @@ internal static void Write(
///
/// Writes the blob for if it doesn't already exist. Streams
- /// straight from through gzip compression
+ /// straight from through Zstd compression
/// and (when is set) chunked AES-256-GCM encryption directly into the
/// destination file — no whole-file buffer is ever materialized, so a single blob's size is
/// not limited by 's ~2 GB cap. New encrypted blobs always use the
/// chunked layout (, flagged via ); see
- /// for the legacy whole-blob layout this format replaces.
+ /// for the legacy whole-blob layout this format replaces. New compressed
+ /// blobs are always flagged — nothing writes gzip anymore, but blobs
+ /// written before this flag existed stay gzip forever since content-addressed blobs already on
+ /// disk are never rewritten.
///
- private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileContent data, long length, ImageCompressionLevel level, byte[]? cek)
+ private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileContent data, long length, ImageCompressionLevel level, byte[]? cek, int? customZstdLevel)
{
var blobPath = HashToBlobPath(blobDirectory, hash);
if (File.Exists(blobPath))
@@ -270,7 +282,7 @@ private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileCon
Directory.CreateDirectory(Path.GetDirectoryName(blobPath)!);
var compress = level != ImageCompressionLevel.None;
- var flag = (compress ? BlobFlagCompressed : 0) | (cek is not null ? BlobFlagEncrypted | BlobFlagChunked : 0);
+ var flag = (compress ? BlobFlagCompressed | BlobFlagZstd : 0) | (cek is not null ? BlobFlagEncrypted | BlobFlagChunked : 0);
var tempPath = blobPath + "." + Guid.NewGuid().ToString("N") + ".tmp";
@@ -292,17 +304,17 @@ private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileCon
if (compress)
{
- var gzip = new GZipStream(target, ToCompressionLevel(level), leaveOpen: true);
+ var zstd = new ParallelZstd.WriteStream(target, level.ToZstdLevel(customZstdLevel));
try
{
- data.CopyTo(gzip, length);
+ data.CopyTo(zstd, length);
}
finally
{
// Explicitly disposed (rather than relying on leaveOpen semantics further
- // up the chain) so the deflate stream's final block is flushed before the
- // chunked encryption below is completed.
- gzip.Dispose();
+ // up the chain) so every outstanding chunk is compressed and flushed before
+ // the chunked encryption below is completed.
+ zstd.Dispose();
}
}
else
@@ -351,6 +363,7 @@ private static FileContent ReadBlob(string blobDirectory, byte[] hash, string no
var compressed = (flag & BlobFlagCompressed) != 0;
var encrypted = (flag & BlobFlagEncrypted) != 0;
var chunked = (flag & BlobFlagChunked) != 0;
+ var useZstd = (flag & BlobFlagZstd) != 0;
Stream plaintextStream;
byte[]? legacyPlaintext = null;
@@ -402,7 +415,9 @@ private static FileContent ReadBlob(string blobDirectory, byte[] hash, string no
}
var sourceStream = compressed
- ? new GZipStream(plaintextStream, CompressionMode.Decompress)
+ ? useZstd
+ ? new ParallelZstd.ReadStream(plaintextStream)
+ : new GZipStream(plaintextStream, CompressionMode.Decompress)
: plaintextStream;
var aligned = FileNode.AlignToAllocationUnit(allocationSize);
@@ -485,52 +500,27 @@ private readonly record struct NodeHeader(
private static (string Path, NodeHeader Header) ReadNodeHeader(BinaryReader reader)
{
- var path = reader.ReadString();
+ var metadata = NodeMetadataIO.ReadMetadata(reader);
+ var fileInfo = metadata.FileInfo;
var header = new NodeHeader(
- FileAttributes: reader.ReadUInt32(),
- AllocationSize: reader.ReadUInt64(),
- FileSize: reader.ReadUInt64(),
- CreationTime: reader.ReadUInt64(),
- LastAccessTime: reader.ReadUInt64(),
- LastWriteTime: reader.ReadUInt64(),
- ChangeTime: reader.ReadUInt64(),
- IndexNumber: reader.ReadUInt64(),
- HardLinks: reader.ReadUInt32(),
- Security: null);
-
- var secLen = reader.ReadInt32();
- var security = secLen > 0 ? reader.ReadBytes(secLen) : null;
-
- return (path, header with
- {
- Security = security
- });
+ FileAttributes: fileInfo.FileAttributes,
+ AllocationSize: fileInfo.AllocationSize,
+ FileSize: fileInfo.FileSize,
+ CreationTime: fileInfo.CreationTime,
+ LastAccessTime: fileInfo.LastAccessTime,
+ LastWriteTime: fileInfo.LastWriteTime,
+ ChangeTime: fileInfo.ChangeTime,
+ IndexNumber: fileInfo.IndexNumber,
+ HardLinks: fileInfo.HardLinks,
+ Security: metadata.Security);
+
+ return (metadata.Path, header);
}
- private static CompressionLevel ToCompressionLevel(ImageCompressionLevel level) => level switch
- {
- ImageCompressionLevel.Fastest => CompressionLevel.Fastest,
- ImageCompressionLevel.SmallestSize => CompressionLevel.SmallestSize,
- _ => CompressionLevel.Optimal,
- };
-
- private static void WriteNode(BinaryWriter writer, string path, FileNode node, string blobDirectory, ImageCompressionLevel level, byte[]? cek)
+ private static void WriteNode(BinaryWriter writer, string path, FileNode node, string blobDirectory, ImageCompressionLevel level, byte[]? cek, int? customZstdLevel)
{
- writer.Write(path);
- writer.Write(node.FileInfo.FileAttributes);
- writer.Write(node.FileInfo.AllocationSize);
- writer.Write(node.FileInfo.FileSize);
- writer.Write(node.FileInfo.CreationTime);
- writer.Write(node.FileInfo.LastAccessTime);
- writer.Write(node.FileInfo.LastWriteTime);
- writer.Write(node.FileInfo.ChangeTime);
- writer.Write(node.FileInfo.IndexNumber);
- writer.Write(node.FileInfo.HardLinks);
-
- var security = node.FileSecurity ?? [];
- writer.Write(security.Length);
- writer.Write(security);
+ NodeMetadataIO.WriteMetadata(writer, path, node);
if (node.IsDirectory)
{
@@ -552,7 +542,7 @@ private static void WriteNode(BinaryWriter writer, string path, FileNode node, s
hash = incrementalHash.GetHashAndReset();
}
- EnsureBlobWritten(blobDirectory, hash, node.FileData, fileSize, level, cek);
+ EnsureBlobWritten(blobDirectory, hash, node.FileData, fileSize, level, cek, customZstdLevel);
writer.Write((byte)1); // HasBlob marker
writer.Write(hash);
diff --git a/src/ManagedDrive.Service/GlobalMountManager.cs b/src/ManagedDrive.Service/GlobalMountManager.cs
index 552eed4..b8dc2c7 100644
--- a/src/ManagedDrive.Service/GlobalMountManager.cs
+++ b/src/ManagedDrive.Service/GlobalMountManager.cs
@@ -2,6 +2,7 @@
using Microsoft.Win32;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
+using ThrottledLogging;
namespace ManagedDrive.Service;
@@ -69,8 +70,9 @@ public void Reconcile()
NativeMethods.RemoveGlobalSymlink(letter, devicePath);
DeleteRegistryEntry(letter);
- logger.LogInformation("Reconciled stale symlink {Letter} -> {Device} (device gone)",
- letter, devicePath);
+ logger.LogInformationThrottled(
+ $"reconciled-stale:{letter}", TimeSpan.FromMinutes(10),
+ "Reconciled stale symlink {Letter} -> {Device} (device gone)", letter, devicePath);
}
}
}
diff --git a/src/ManagedDrive.Service/HelperPipeService.cs b/src/ManagedDrive.Service/HelperPipeService.cs
index 5091c29..a8221fd 100644
--- a/src/ManagedDrive.Service/HelperPipeService.cs
+++ b/src/ManagedDrive.Service/HelperPipeService.cs
@@ -4,6 +4,7 @@
using ManagedDrive.HelperProtocol;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
+using ThrottledLogging;
namespace ManagedDrive.Service;
@@ -39,7 +40,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
catch (Exception ex)
{
// Best-effort — a malformed or interrupted request must not take down the loop.
- logger.LogWarning(ex, "Pipe connection handling failed.");
+ logger.LogWarningThrottled(
+ "pipe-connection-failed", TimeSpan.FromMinutes(5),
+ "Pipe connection handling failed: {Error}", ex.Message);
}
}
}
diff --git a/src/ManagedDrive.Service/ManagedDrive.Service.csproj b/src/ManagedDrive.Service/ManagedDrive.Service.csproj
index 10de55c..2346449 100644
--- a/src/ManagedDrive.Service/ManagedDrive.Service.csproj
+++ b/src/ManagedDrive.Service/ManagedDrive.Service.csproj
@@ -8,6 +8,7 @@
+
diff --git a/tests/ManagedDrive.Tests/CreateDiskOptionsBuilderTests.cs b/tests/ManagedDrive.Tests/CreateDiskOptionsBuilderTests.cs
index b695096..f87a184 100644
--- a/tests/ManagedDrive.Tests/CreateDiskOptionsBuilderTests.cs
+++ b/tests/ManagedDrive.Tests/CreateDiskOptionsBuilderTests.cs
@@ -343,6 +343,54 @@ private static CreateDiskInput EncryptedInput(out DirectoryInfo dir, string p1,
};
}
+ [Fact]
+ public void Build_CustomZstdLevelDisabled_LeavesCustomZstdLevelNull()
+ {
+ var result = CreateDiskOptionsBuilder.Build(ValidCreateInput());
+
+ Assert.True(result.Success);
+ Assert.Null(result.Options!.CustomZstdLevel);
+ }
+
+ [Fact]
+ public void Build_CustomZstdLevelEnabled_SetsCustomZstdLevel()
+ {
+ var input = ValidCreateInput() with { CustomZstdLevelEnabled = true, CustomZstdLevelValue = 12 };
+
+ var result = CreateDiskOptionsBuilder.Build(input);
+
+ Assert.True(result.Success);
+ Assert.Equal(12, result.Options!.CustomZstdLevel);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(23)]
+ public void Build_CustomZstdLevelOutOfRange_ReturnsBadCustomZstdLevel(int value)
+ {
+ var input = ValidCreateInput() with { CustomZstdLevelEnabled = true, CustomZstdLevelValue = value };
+
+ var result = CreateDiskOptionsBuilder.Build(input);
+
+ Assert.Equal(CreateDiskValidationError.BadCustomZstdLevel, result.Error);
+ }
+
+ [Fact]
+ public void Build_CustomZstdLevelEnabledButCompressionNone_IgnoresCustomZstdLevel()
+ {
+ var input = ValidCreateInput() with
+ {
+ CompressionLevel = ImageCompressionLevel.None,
+ CustomZstdLevelEnabled = true,
+ CustomZstdLevelValue = 12,
+ };
+
+ var result = CreateDiskOptionsBuilder.Build(input);
+
+ Assert.True(result.Success);
+ Assert.Null(result.Options!.CustomZstdLevel);
+ }
+
private static CreateDiskInput ValidCreateInput() => new()
{
MountPoint = "Z:",
diff --git a/tests/ManagedDrive.Tests/DiskImageSerializerTests.cs b/tests/ManagedDrive.Tests/DiskImageSerializerTests.cs
index e0b8002..3185444 100644
--- a/tests/ManagedDrive.Tests/DiskImageSerializerTests.cs
+++ b/tests/ManagedDrive.Tests/DiskImageSerializerTests.cs
@@ -398,6 +398,169 @@ public void Load_LegacyVersion3EncryptedWholeBlobImage_StillLoads()
}
}
+ [Fact]
+ public void Save_WritesVersion5Header()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mdr");
+ try
+ {
+ var map = new FileNodeMap();
+ map.Add("\\", MakeDir());
+ DiskImageSerializer.Save(map, capacityBytes: 1024 * 1024, "MyLabel", path, ImageCompressionLevel.Fastest);
+
+ using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
+ using var reader = new BinaryReader(stream);
+ reader.ReadBytes(4); // magic
+ Assert.Equal(5, reader.ReadInt32());
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Theory]
+ [InlineData(ImageCompressionLevel.Fastest)]
+ [InlineData(ImageCompressionLevel.Optimal)]
+ [InlineData(ImageCompressionLevel.SmallestSize)]
+ public void Save_Compressed_DoesNotUseGzip(ImageCompressionLevel level)
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mdr");
+ try
+ {
+ var map = new FileNodeMap();
+ map.Add("\\", MakeDir());
+ map.Add("\\File.txt", MakeFile("hello world, this is compressible content"u8.ToArray()));
+ DiskImageSerializer.Save(map, capacityBytes: 1024 * 1024, "MyLabel", path, level);
+
+ var bytes = File.ReadAllBytes(path);
+
+ // The gzip magic (0x1F 0x8B) should not appear right at the start of the node
+ // region — a weak but simple signal that the payload was Zstd- rather than
+ // gzip-compressed. (The node region starts right after the plaintext header.)
+ var headerLength = 4 + 4 + 1 + 1 + 8 + (4 + "MyLabel".Length);
+ Assert.False(bytes[headerLength] == 0x1F && bytes[headerLength + 1] == 0x8B);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void Load_LegacyVersion4GzipCompressedImage_StillLoads()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mdr");
+ try
+ {
+ byte[] nodeRegion;
+ using (var nodeRegionStream = new MemoryStream())
+ {
+ using (var gzip = new GZipStream(nodeRegionStream, CompressionLevel.Fastest, leaveOpen: true))
+ using (var payloadWriter = new BinaryWriter(gzip, System.Text.Encoding.UTF8, leaveOpen: true))
+ {
+ payloadWriter.Write(0); // node count
+ }
+
+ nodeRegion = nodeRegionStream.ToArray();
+ }
+
+ using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write))
+ using (var writer = new BinaryWriter(stream, System.Text.Encoding.UTF8))
+ {
+ writer.Write("MDRD"u8.ToArray());
+ writer.Write(4); // legacy gzip-compressed version, unencrypted
+ writer.Write((byte)ImageCompressionLevel.Fastest);
+ writer.Write((byte)0); // isEncrypted
+ writer.Write(2048UL);
+ writer.Write("LegacyGzipLabel");
+ writer.Write(nodeRegion);
+ }
+
+ var loaded = DiskImageSerializer.Load(path, out var capacityBytes, out var volumeLabel, password: null, out var cek);
+
+ Assert.Equal(2048UL, capacityBytes);
+ Assert.Equal("LegacyGzipLabel", volumeLabel);
+ Assert.Equal(0, loaded.Count);
+ Assert.Null(cek);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void SaveThenLoad_WithParallelZstdAcrossMultipleChunks_RoundTrips()
+ {
+ // Force a tiny chunk size so a handful of KB of node data spans several independently
+ // compressed chunks, exercising the parallel Zstd compression path without allocating a
+ // real multi-megabyte buffer.
+ ParallelZstd.TestChunkSizeOverride = 64;
+ var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mdr");
+ try
+ {
+ var map = new FileNodeMap();
+ map.Add("\\", MakeDir());
+ for (var i = 0; i < 50; i++)
+ {
+ map.Add($"\\File{i}.txt", MakeFile(System.Text.Encoding.UTF8.GetBytes($"content for file number {i}")));
+ }
+
+ DiskImageSerializer.Save(map, capacityBytes: 1024 * 1024, "ChunkedLabel", path, ImageCompressionLevel.Fastest);
+
+ var loaded = DiskImageSerializer.Load(path, out var capacityBytes, out var volumeLabel, password: null, out var cek);
+
+ Assert.Equal(1024UL * 1024, capacityBytes);
+ Assert.Equal("ChunkedLabel", volumeLabel);
+ Assert.Equal(51, loaded.Count);
+ for (var i = 0; i < 50; i++)
+ {
+ Assert.True(loaded.TryGet($"\\File{i}.txt", out var node));
+ var expected = System.Text.Encoding.UTF8.GetBytes($"content for file number {i}");
+ Assert.Equal(expected, node!.FileData!.ToArray(expected.Length));
+ }
+
+ Assert.Null(cek);
+ }
+ finally
+ {
+ ParallelZstd.TestChunkSizeOverride = null;
+ File.Delete(path);
+ }
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(19)]
+ [InlineData(22)]
+ public void SaveThenLoad_WithCustomZstdLevel_RoundTrips(int customZstdLevel)
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mdr");
+ try
+ {
+ var map = new FileNodeMap();
+ map.Add("\\", MakeDir());
+ map.Add("\\File.txt", MakeFile("hello world"u8.ToArray()));
+
+ DiskImageSerializer.Save(map, capacityBytes: 1024 * 1024, "MyLabel", path, ImageCompressionLevel.Fastest,
+ customZstdLevel: customZstdLevel);
+
+ var loaded = DiskImageSerializer.Load(path, out var capacityBytes, out var volumeLabel, password: null, out var cek);
+
+ Assert.Equal(1024UL * 1024, capacityBytes);
+ Assert.Equal("MyLabel", volumeLabel);
+ Assert.Equal(2, loaded.Count);
+ Assert.True(loaded.TryGet("\\File.txt", out var node));
+ Assert.Equal("hello world"u8.ToArray(), node!.FileData!.ToArray("hello world"u8.Length));
+ Assert.Null(cek);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
private static FileNode MakeDir() => new()
{
FileInfo = { FileAttributes = (uint)FileAttributes.Directory },
diff --git a/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs b/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs
new file mode 100644
index 0000000..f5e475d
--- /dev/null
+++ b/tests/ManagedDrive.Tests/DiskProfileMappingTests.cs
@@ -0,0 +1,49 @@
+using ManagedDrive.App.ViewModels;
+
+namespace ManagedDrive.Tests;
+
+public sealed class DiskProfileMappingTests
+{
+ [Fact]
+ public void ToProfile_ThenProfileToOptions_RoundTripsEveryField()
+ {
+ var options = new DiskOptions
+ {
+ MountPoint = "R:",
+ VolumeLabel = "Test Label",
+ CapacityBytes = 123_456_789UL,
+ ReadOnly = true,
+ AutoMount = true,
+ PersistImagePath = @"C:\images\disk.mdr",
+ SourceArchivePath = @"C:\archives\disk.zip",
+ AutoSaveIntervalMinutes = 15,
+ CompressionLevel = ImageCompressionLevel.SmallestSize,
+ CustomZstdLevel = 19,
+ MaxSnapshotCount = 7,
+ MaxSnapshotSizeBytes = 999_000_000UL,
+ HighUsageWarnPercent = 85.5,
+ SaveImageOnExit = false,
+ };
+
+ var profile = MainViewModel.ToProfile(options);
+ var roundTripped = MainViewModel.ProfileToOptions(profile);
+
+ Assert.Equal(options, roundTripped);
+ }
+
+ [Fact]
+ public void ToProfile_ThenProfileToOptions_RoundTripsNullableFieldsWhenUnset()
+ {
+ var options = new DiskOptions
+ {
+ MountPoint = "S:",
+ VolumeLabel = "Minimal",
+ CapacityBytes = 1_048_576UL,
+ };
+
+ var profile = MainViewModel.ToProfile(options);
+ var roundTripped = MainViewModel.ProfileToOptions(profile);
+
+ Assert.Equal(options, roundTripped);
+ }
+}
diff --git a/tests/ManagedDrive.Tests/MountOptionsFactoryTests.cs b/tests/ManagedDrive.Tests/MountOptionsFactoryTests.cs
index f795696..5a1fff2 100644
--- a/tests/ManagedDrive.Tests/MountOptionsFactoryTests.cs
+++ b/tests/ManagedDrive.Tests/MountOptionsFactoryTests.cs
@@ -80,6 +80,46 @@ public void BuildImageOptions_OverridesWinOverProfile()
Assert.Equal(5U, options.MaxSnapshotCount);
}
+ [Fact]
+ public void BuildImageOptions_CustomZstdLevelOverrideWinsOverProfile()
+ {
+ var profile = new DiskOptions
+ {
+ MountPoint = "OLD:",
+ CapacityBytes = 1,
+ VolumeLabel = "L",
+ PersistImagePath = Image,
+ CompressionLevel = ImageCompressionLevel.Optimal,
+ CustomZstdLevel = 5,
+ };
+
+ var overrides = new MountOverrides { CustomZstdLevel = 19 };
+
+ var options = MountOptionsFactory.BuildImageOptions(
+ profile, "R:", Image, 4UL * 1024 * 1024, "L", overrides);
+
+ Assert.Equal(19, options.CustomZstdLevel);
+ }
+
+ [Fact]
+ public void BuildImageOptions_NoCustomZstdLevelOverride_ReusesProfileValue()
+ {
+ var profile = new DiskOptions
+ {
+ MountPoint = "OLD:",
+ CapacityBytes = 1,
+ VolumeLabel = "L",
+ PersistImagePath = Image,
+ CompressionLevel = ImageCompressionLevel.Optimal,
+ CustomZstdLevel = 5,
+ };
+
+ var options = MountOptionsFactory.BuildImageOptions(
+ profile, "R:", Image, 4UL * 1024 * 1024, "L", overrides: new());
+
+ Assert.Equal(5, options.CustomZstdLevel);
+ }
+
[Fact]
public void BuildArchiveOptions_ForcesReadOnlyAndSetsSourcePath()
{
diff --git a/tests/ManagedDrive.Tests/SnapshotManagerTests.cs b/tests/ManagedDrive.Tests/SnapshotManagerTests.cs
index 7054924..9b2e1d2 100644
--- a/tests/ManagedDrive.Tests/SnapshotManagerTests.cs
+++ b/tests/ManagedDrive.Tests/SnapshotManagerTests.cs
@@ -1,3 +1,4 @@
+using System.IO.Compression;
using System.Security.Cryptography;
namespace ManagedDrive.Tests;
@@ -414,6 +415,87 @@ public void LoadSnapshot_LegacyWholeBlobEncryptedBlob_StillLoads()
Assert.Equal(content, node!.FileData!.ToArray(content.Length));
}
+ [Fact]
+ public void WriteSnapshot_CompressedBlob_FlagsZstd()
+ {
+ WriteSnapshotWithFile(new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), "\\a.txt",
+ "hello world, this is compressible content"u8.ToArray(), ImageCompressionLevel.Fastest);
+
+ var blobPath = Directory.EnumerateFiles(BlobDirectory, "*.blob", SearchOption.AllDirectories).Single();
+ var flag = File.ReadAllBytes(blobPath)[0];
+
+ Assert.Equal(0b1001, flag); // Compressed | Zstd
+ }
+
+ [Fact]
+ public void LoadSnapshot_LegacyGzipCompressedBlob_StillLoads()
+ {
+ var content = "hello world, this is compressible content"u8.ToArray();
+
+ // Write a normal snapshot first so the index/blob directory exist with the expected
+ // layout, then hand-overwrite the blob it produced in the pre-Zstd gzip format (flag
+ // without the Zstd bit) to simulate a blob written before BlobFlagZstd existed.
+ var nodeMap = new FileNodeMap();
+ nodeMap.Add("\\", MakeDir());
+ nodeMap.Add("\\a.txt", MakeFile(content));
+ SnapshotManager.WriteSnapshot(nodeMap, 1024, "Label", _mainImagePath,
+ new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), ImageCompressionLevel.Fastest);
+
+ var blobPath = Directory.EnumerateFiles(BlobDirectory, "*.blob", SearchOption.AllDirectories).Single();
+
+ using (var stream = new FileStream(blobPath, FileMode.Create, FileAccess.Write))
+ {
+ stream.WriteByte(0b001); // Compressed, not Zstd (legacy gzip), not Encrypted
+ using var gzip = new GZipStream(stream, CompressionLevel.Fastest, leaveOpen: true);
+ gzip.Write(content);
+ }
+
+ var snapshot = Assert.Single(SnapshotManager.ListSnapshots(_mainImagePath));
+ var loaded = SnapshotManager.LoadSnapshot(snapshot.Path, out _, out _);
+
+ Assert.True(loaded.TryGet("\\a.txt", out var node));
+ Assert.Equal(content, node!.FileData!.ToArray(content.Length));
+ }
+
+ [Fact]
+ public void LoadSnapshot_MixedGzipAndZstdBlobsInSameIndex_BothLoad()
+ {
+ var legacyContent = "hello world, this is compressible content"u8.ToArray();
+ var freshContent = "a completely different piece of content"u8.ToArray();
+
+ var nodeMap = new FileNodeMap();
+ nodeMap.Add("\\", MakeDir());
+ nodeMap.Add("\\legacy.txt", MakeFile(legacyContent));
+ SnapshotManager.WriteSnapshot(nodeMap, 1024, "Label", _mainImagePath,
+ new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), ImageCompressionLevel.Fastest);
+
+ // Hand-overwrite the first snapshot's blob in the pre-Zstd gzip format, simulating
+ // content written before Zstd support existed.
+ var legacyBlobPath = Directory.EnumerateFiles(BlobDirectory, "*.blob", SearchOption.AllDirectories).Single();
+ using (var stream = new FileStream(legacyBlobPath, FileMode.Create, FileAccess.Write))
+ {
+ stream.WriteByte(0b001); // Compressed, not Zstd
+ using var gzip = new GZipStream(stream, CompressionLevel.Fastest, leaveOpen: true);
+ gzip.Write(legacyContent);
+ }
+
+ // A second snapshot adds a new file, which is written fresh (Zstd), while still
+ // referencing the untouched legacy gzip blob for the unchanged file.
+ nodeMap.Add("\\fresh.txt", MakeFile(freshContent));
+ SnapshotManager.WriteSnapshot(nodeMap, 1024, "Label", _mainImagePath,
+ new(2026, 1, 2, 0, 0, 0, TimeSpan.Zero), ImageCompressionLevel.Fastest);
+
+ Assert.Equal(2, BlobCount);
+
+ var latest = SnapshotManager.ListSnapshots(_mainImagePath).OrderBy(s => s.TimestampUtc).Last();
+ var loaded = SnapshotManager.LoadSnapshot(latest.Path, out _, out _);
+
+ Assert.True(loaded.TryGet("\\legacy.txt", out var legacyNode));
+ Assert.Equal(legacyContent, legacyNode!.FileData!.ToArray(legacyContent.Length));
+ Assert.True(loaded.TryGet("\\fresh.txt", out var freshNode));
+ Assert.Equal(freshContent, freshNode!.FileData!.ToArray(freshContent.Length));
+ }
+
[Fact]
public void LoadSnapshot_TruncatedBlob_ThrowsInvalidData()
{
@@ -709,11 +791,12 @@ private static FileNode MakeFile(byte[] content)
};
}
- private void WriteSnapshotWithFile(DateTimeOffset timestampUtc, string path, byte[] content)
+ private void WriteSnapshotWithFile(DateTimeOffset timestampUtc, string path, byte[] content,
+ ImageCompressionLevel level = ImageCompressionLevel.None)
{
var nodeMap = new FileNodeMap();
nodeMap.Add("\\", MakeDir());
nodeMap.Add(path, MakeFile(content));
- SnapshotManager.WriteSnapshot(nodeMap, 1024, "Label", _mainImagePath, timestampUtc, ImageCompressionLevel.None);
+ SnapshotManager.WriteSnapshot(nodeMap, 1024, "Label", _mainImagePath, timestampUtc, level);
}
}
\ No newline at end of file
diff --git a/tests/ManagedDrive.Tests/ZstdConcatenatedFramesTests.cs b/tests/ManagedDrive.Tests/ZstdConcatenatedFramesTests.cs
new file mode 100644
index 0000000..8487534
--- /dev/null
+++ b/tests/ManagedDrive.Tests/ZstdConcatenatedFramesTests.cs
@@ -0,0 +1,47 @@
+using ZstdSharp;
+
+namespace ManagedDrive.Tests;
+
+///
+/// Guards the assumption the parallel Zstd node-region writer relies on: independently
+/// compressed Zstd frames, written back-to-back into one stream, decompress transparently as
+/// if they were a single frame. If this ever stopped being true, DecompressionStream
+/// would need explicit per-chunk framing instead.
+///
+public sealed class ZstdConcatenatedFramesTests
+{
+ [Fact]
+ public void DecompressionStream_ReadsMultipleConcatenatedIndependentFrames()
+ {
+ var chunk1 = System.Text.Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("Hello World! ", 500)));
+ var chunk2 = System.Text.Encoding.UTF8.GetBytes(string.Concat(Enumerable.Repeat("Goodbye World! ", 500)));
+
+ byte[] frame1, frame2;
+ using (var c1 = new Compressor(3))
+ {
+ frame1 = c1.Wrap(chunk1).ToArray();
+ }
+
+ using (var c2 = new Compressor(3))
+ {
+ frame2 = c2.Wrap(chunk2).ToArray();
+ }
+
+ using var concatenated = new MemoryStream();
+ concatenated.Write(frame1);
+ concatenated.Write(frame2);
+ concatenated.Position = 0;
+
+ using var decompressed = new MemoryStream();
+ using (var ds = new DecompressionStream(concatenated))
+ {
+ ds.CopyTo(decompressed);
+ }
+
+ var expected = new byte[chunk1.Length + chunk2.Length];
+ Buffer.BlockCopy(chunk1, 0, expected, 0, chunk1.Length);
+ Buffer.BlockCopy(chunk2, 0, expected, chunk1.Length, chunk2.Length);
+
+ Assert.Equal(expected, decompressed.ToArray());
+ }
+}