diff --git a/src/ManagedDrive.App/Controls/SpeedHistoryChart.xaml b/src/ManagedDrive.App/Controls/SpeedHistoryChart.xaml
new file mode 100644
index 0000000..62cd61d
--- /dev/null
+++ b/src/ManagedDrive.App/Controls/SpeedHistoryChart.xaml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ManagedDrive.App/Controls/SpeedHistoryChart.xaml.cs b/src/ManagedDrive.App/Controls/SpeedHistoryChart.xaml.cs
new file mode 100644
index 0000000..a198b7c
--- /dev/null
+++ b/src/ManagedDrive.App/Controls/SpeedHistoryChart.xaml.cs
@@ -0,0 +1,119 @@
+using System.Windows.Media;
+using ManagedDrive.Cli.Core;
+
+namespace ManagedDrive.App.Controls;
+
+///
+/// Renders a fixed-size read/write speed history as two overlaid polylines. Intended for use
+/// inside a hover-triggered — it is not
+/// rendered while off-screen, so no visibility/throttling logic lives here.
+///
+public partial class SpeedHistoryChart
+{
+ /// Identifies the dependency property.
+ public static readonly DependencyProperty ReadHistoryProperty = DependencyProperty.Register(
+ nameof(ReadHistory), typeof(IReadOnlyList), typeof(SpeedHistoryChart),
+ new PropertyMetadata(null, OnHistoryChanged));
+
+ /// Identifies the dependency property.
+ public static readonly DependencyProperty WriteHistoryProperty = DependencyProperty.Register(
+ nameof(WriteHistory), typeof(IReadOnlyList), typeof(SpeedHistoryChart),
+ new PropertyMetadata(null, OnHistoryChanged));
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ public SpeedHistoryChart()
+ {
+ InitializeComponent();
+ }
+
+ ///
+ /// Gets or sets the oldest-first read-speed history (bytes/sec) to plot.
+ ///
+ public IReadOnlyList? ReadHistory
+ {
+ get => (IReadOnlyList?)GetValue(ReadHistoryProperty);
+ set => SetValue(ReadHistoryProperty, value);
+ }
+
+ ///
+ /// Gets or sets the oldest-first write-speed history (bytes/sec) to plot.
+ ///
+ public IReadOnlyList? WriteHistory
+ {
+ get => (IReadOnlyList?)GetValue(WriteHistoryProperty);
+ set => SetValue(WriteHistoryProperty, value);
+ }
+
+ ///
+ /// Maps a sequence of values onto polyline points that fit within
+ /// x , scaled by the largest value across
+ /// both series () so the read and write lines share one scale.
+ /// A flat, near-baseline line is returned when every value is zero, rather than an empty
+ /// point list, so the chart never looks unloaded.
+ ///
+ public static PointCollection NormalizePoints(IReadOnlyList values, double width, double height, double scaleMax)
+ {
+ var points = new PointCollection();
+ if (values.Count == 0)
+ {
+ return points;
+ }
+
+ var stepX = values.Count > 1 ? width / (values.Count - 1) : 0;
+ for (var i = 0; i < values.Count; i++)
+ {
+ var x = i * stepX;
+ var y = scaleMax > 0
+ ? height - (values[i] / scaleMax * height)
+ : height;
+ points.Add(new(x, y));
+ }
+
+ return points;
+ }
+
+ private static void OnHistoryChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) =>
+ ((SpeedHistoryChart)d).Redraw();
+
+ private void ChartArea_SizeChanged(object sender, SizeChangedEventArgs e) => Redraw();
+
+ private void Redraw()
+ {
+ var width = ChartArea.ActualWidth;
+ var height = ChartArea.ActualHeight;
+ if (width <= 0 || height <= 0)
+ {
+ return;
+ }
+
+ var read = ReadHistory ?? Array.Empty();
+ var write = WriteHistory ?? Array.Empty();
+ var scaleMax = Math.Max(
+ read.Count > 0 ? read.Max() : 0.0,
+ write.Count > 0 ? write.Max() : 0.0);
+
+ ReadLine.Points = NormalizePoints(read, width, height, scaleMax);
+ WriteLine.Points = NormalizePoints(write, width, height, scaleMax);
+
+ TopGridLine.X1 = 0;
+ TopGridLine.Y1 = 0;
+ TopGridLine.X2 = width;
+ TopGridLine.Y2 = 0;
+
+ MidGridLine.X1 = 0;
+ MidGridLine.Y1 = height / 2;
+ MidGridLine.X2 = width;
+ MidGridLine.Y2 = height / 2;
+
+ BottomGridLine.X1 = 0;
+ BottomGridLine.Y1 = height;
+ BottomGridLine.X2 = width;
+ BottomGridLine.Y2 = height;
+
+ AxisMaxLabel.Text = scaleMax > 0 ? ByteFormatter.FormatRate(scaleMax) : string.Empty;
+ AxisMidLabel.Text = scaleMax > 0 ? ByteFormatter.FormatRate(scaleMax / 2) : string.Empty;
+ AxisZeroLabel.Text = "0 B/s";
+ }
+}
diff --git a/src/ManagedDrive.App/GlobalUsings.cs b/src/ManagedDrive.App/GlobalUsings.cs
index 18380e2..23aa094 100644
--- a/src/ManagedDrive.App/GlobalUsings.cs
+++ b/src/ManagedDrive.App/GlobalUsings.cs
@@ -1,4 +1,5 @@
global using ManagedDrive.App.Cli;
+global using ManagedDrive.App.Controls;
global using ManagedDrive.App.Infrastructure;
global using ManagedDrive.App.Localization;
global using ManagedDrive.App.Models;
diff --git a/src/ManagedDrive.App/Localization/Strings.en-US.xaml b/src/ManagedDrive.App/Localization/Strings.en-US.xaml
index 788e22c..3d204cc 100644
--- a/src/ManagedDrive.App/Localization/Strings.en-US.xaml
+++ b/src/ManagedDrive.App/Localization/Strings.en-US.xaml
@@ -89,6 +89,8 @@
Not yet modified
Last image save: {0}
Image not yet saved
+ Read speed
+ Write speed
Just now
{0} min ago
{0} hours ago
diff --git a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml
index 8cfa94a..66435f5 100644
--- a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml
+++ b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml
@@ -89,6 +89,8 @@
尚未写入
上次保存镜像:{0}
镜像尚未保存
+ 读取速度
+ 写入速度
刚才
{0} 分钟前
{0} 小时前
diff --git a/src/ManagedDrive.App/MainWindow.xaml b/src/ManagedDrive.App/MainWindow.xaml
index 51ca860..fa7bd9a 100644
--- a/src/ManagedDrive.App/MainWindow.xaml
+++ b/src/ManagedDrive.App/MainWindow.xaml
@@ -5,6 +5,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:ManagedDrive.App.ViewModels"
xmlns:infra="clr-namespace:ManagedDrive.App.Infrastructure"
+ xmlns:controls="clr-namespace:ManagedDrive.App.Controls"
mc:Ignorable="d"
Style="{StaticResource AppWindow}"
WindowStyle="None"
@@ -400,6 +401,37 @@
Foreground="{DynamicResource AppForegroundLight}"
Visibility="{Binding ShowLastAutoSave, Converter={StaticResource BoolToVis}}"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ManagedDrive.App/MainWindow.xaml.cs b/src/ManagedDrive.App/MainWindow.xaml.cs
index dec517b..ad44ec3 100644
--- a/src/ManagedDrive.App/MainWindow.xaml.cs
+++ b/src/ManagedDrive.App/MainWindow.xaml.cs
@@ -1,3 +1,7 @@
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Input;
+
namespace ManagedDrive.App;
///
@@ -5,6 +9,11 @@ namespace ManagedDrive.App;
///
public partial class MainWindow
{
+ private static readonly TimeSpan SpeedPopupCloseDelay = TimeSpan.FromMilliseconds(150);
+
+ private DispatcherTimer? _speedPopupCloseTimer;
+ private Popup? _pendingCloseSpeedPopup;
+
///
/// Initializes the main window and binds the supplied view model.
///
@@ -33,4 +42,68 @@ private void OpenAttachedContextMenu(object sender)
btn.ContextMenu.IsOpen = true;
}
}
+
+ ///
+ /// Opens the speed-history popup when the mouse enters the speed row or the popup itself,
+ /// cancelling any pending close scheduled for a different popup.
+ ///
+ private void SpeedRow_MouseEnter(object sender, MouseEventArgs e)
+ {
+ var popup = FindSpeedPopup(sender);
+ if (popup is null)
+ {
+ return;
+ }
+
+ if (_pendingCloseSpeedPopup is not null && _pendingCloseSpeedPopup != popup)
+ {
+ CloseSpeedPopup(_pendingCloseSpeedPopup);
+ }
+
+ _speedPopupCloseTimer?.Stop();
+ _pendingCloseSpeedPopup = null;
+ popup.IsOpen = true;
+ }
+
+ ///
+ /// Schedules the speed-history popup to close shortly after the mouse leaves the speed row
+ /// or the popup itself, so briefly crossing the gap between them doesn't flicker it shut.
+ ///
+ private void SpeedRow_MouseLeave(object sender, MouseEventArgs e)
+ {
+ var popup = FindSpeedPopup(sender);
+ if (popup is null)
+ {
+ return;
+ }
+
+ _speedPopupCloseTimer?.Stop();
+ _pendingCloseSpeedPopup = popup;
+ _speedPopupCloseTimer = new DispatcherTimer { Interval = SpeedPopupCloseDelay };
+ _speedPopupCloseTimer.Tick += (_, _) =>
+ {
+ _speedPopupCloseTimer?.Stop();
+ CloseSpeedPopup(popup);
+ _pendingCloseSpeedPopup = null;
+ };
+ _speedPopupCloseTimer.Start();
+ }
+
+ private static void CloseSpeedPopup(Popup popup) => popup.IsOpen = false;
+
+ ///
+ /// Resolves the speed-history for a mouse event raised either by the
+ /// speed row itself or by the popup's own content border. The border's Tag is bound to
+ /// the popup by name in XAML (Tag="{Binding ElementName=SpeedPopup}") rather than
+ /// resolved here via FrameworkElement.Parent — a 's child is hosted
+ /// in a separate visual root, so relying on logical-tree parentage to hold at arbitrary
+ /// MouseEnter/MouseLeave timing is fragile; the explicit binding is resolved once when the
+ /// template loads, before any such event can fire.
+ ///
+ private static Popup? FindSpeedPopup(object sender) => sender switch
+ {
+ StackPanel speedRow => speedRow.Children.OfType().FirstOrDefault(),
+ FrameworkElement { Tag: Popup popup } => popup,
+ _ => null,
+ };
}
\ No newline at end of file
diff --git a/src/ManagedDrive.App/Services/DiskNotificationService.cs b/src/ManagedDrive.App/Services/DiskNotificationService.cs
index 8b83ad6..d4b194e 100644
--- a/src/ManagedDrive.App/Services/DiskNotificationService.cs
+++ b/src/ManagedDrive.App/Services/DiskNotificationService.cs
@@ -7,6 +7,7 @@ namespace ManagedDrive.App.Services;
///
public sealed class DiskNotificationService
{
+ private readonly HashSet _highUsageDisks = [];
private readonly Func _isMainWindowVisible;
private readonly MainViewModel _mainViewModel;
private readonly TrayIconController _trayIconController;
@@ -33,12 +34,19 @@ public DiskNotificationService(MainViewModel mainViewModel, TrayIconController t
vm.HighUsageWarning += OnDiskHighUsageWarning;
vm.SaveFailed += OnDiskSaveFailed;
vm.ActivityObserved += OnDiskActivityObserved;
+ vm.PropertyChanged += OnDiskPropertyChanged;
vm.SetActivityTrackingEnabled(_isMainWindowVisible());
if (vm is { CapacityAdjustedOnLoad: true, Disk.Options.SourceArchivePath: null })
{
OnDiskCapacityAdjusted(vm);
}
+
+ if (vm.IsHighUsage)
+ {
+ _highUsageDisks.Add(vm);
+ _trayIconController.SetHighUsageWarningActive(true);
+ }
}
}
@@ -49,6 +57,12 @@ public DiskNotificationService(MainViewModel mainViewModel, TrayIconController t
vm.HighUsageWarning -= OnDiskHighUsageWarning;
vm.SaveFailed -= OnDiskSaveFailed;
vm.ActivityObserved -= OnDiskActivityObserved;
+ vm.PropertyChanged -= OnDiskPropertyChanged;
+
+ if (_highUsageDisks.Remove(vm))
+ {
+ _trayIconController.SetHighUsageWarningActive(_highUsageDisks.Count > 0);
+ }
}
}
};
@@ -76,6 +90,32 @@ private void OnDiskCapacityAdjusted(DiskViewModel vm)
_mainViewModel.StatusText = Loc.Format("Status.CapacityAdjusted", vm.MountPoint, originalMb, newMb);
}
+ ///
+ /// Tracks every disk currently in the state (which
+ /// fires on both the rising and falling edge, unlike the one-shot
+ /// event used for the balloon tip below) and reduces it to a single tray blink call: the tray
+ /// icon has no per-disk concept, so it only needs to know whether any disk is currently over
+ /// its threshold.
+ ///
+ private void OnDiskPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName != nameof(DiskViewModel.IsHighUsage) || sender is not DiskViewModel vm)
+ {
+ return;
+ }
+
+ if (vm.IsHighUsage)
+ {
+ _highUsageDisks.Add(vm);
+ }
+ else
+ {
+ _highUsageDisks.Remove(vm);
+ }
+
+ _trayIconController.SetHighUsageWarningActive(_highUsageDisks.Count > 0);
+ }
+
private void OnDiskHighUsageWarning(object? sender, EventArgs e)
{
if (sender is not DiskViewModel vm)
diff --git a/src/ManagedDrive.App/Services/TrayIconController.cs b/src/ManagedDrive.App/Services/TrayIconController.cs
index 2486ac3..0e6ce9b 100644
--- a/src/ManagedDrive.App/Services/TrayIconController.cs
+++ b/src/ManagedDrive.App/Services/TrayIconController.cs
@@ -1,3 +1,4 @@
+using System.Drawing.Drawing2D;
using System.Runtime.InteropServices;
namespace ManagedDrive.App.Services;
@@ -16,21 +17,33 @@ public sealed class TrayIconController : IDisposable
///
private static readonly TimeSpan ActivityFlashDuration = TimeSpan.FromMilliseconds(300);
+ ///
+ /// Toggle interval for the high-usage warning blink, driven by .
+ /// Deliberately slower than so the two remain visually distinct.
+ ///
+ private static readonly TimeSpan HighUsageBlinkInterval = TimeSpan.FromMilliseconds(800);
+
private readonly Dispatcher _dispatcher;
private readonly DispatcherTimer _timerActivityFlash = new()
{
Interval = ActivityFlashDuration
};
- private readonly IntPtr[] _trayActivityIconHandles = new IntPtr[3];
+ private readonly DispatcherTimer _timerHighUsageBlink = new()
+ {
+ Interval = HighUsageBlinkInterval
+ };
+ private readonly IntPtr[] _trayActivityIconHandles = new IntPtr[4];
///
- /// [0] normal, [1] read indicator, [2] write indicator. Generated once at startup from the
- /// base tray icon.
+ /// [0] normal, [1] read indicator, [2] write indicator, [3] high-usage warning indicator.
+ /// Generated once at startup from the base tray icon.
///
- private readonly Icon?[] _trayActivityIcons = new Icon?[3];
+ private readonly Icon?[] _trayActivityIcons = new Icon?[4];
private readonly System.Windows.Forms.NotifyIcon _trayIcon;
private readonly Icon _trayIconNormal;
+ private bool _blinkOn;
+ private bool _isHighUsageActive;
///
/// Builds the tray icon, its context menu, and the activity-flash timer.
@@ -84,10 +97,22 @@ public TrayIconController(
_timerActivityFlash.Tick += (_, _) =>
{
- SetTrayIcon(_trayActivityIcons[0]);
+ SetTrayIcon(_isHighUsageActive && _blinkOn ? _trayActivityIcons[3] : _trayActivityIcons[0]);
_timerActivityFlash.Stop();
};
+ _timerHighUsageBlink.Tick += (_, _) =>
+ {
+ _blinkOn = !_blinkOn;
+
+ // Let an in-progress read/write flash keep showing; its own Tick will pick up the
+ // right idle/warning icon (see above) once it reverts.
+ if (!_timerActivityFlash.IsEnabled)
+ {
+ SetTrayIcon(_trayActivityIcons[_blinkOn ? 3 : 0]);
+ }
+ };
+
LanguageManager.Instance.LanguageChanged += (_, _) => UpdateTrayMenuHeaders();
ApplyTrayMenuTheme();
ThemeManager.Instance.ThemeChanged += (_, _) => dispatcher.Invoke(ApplyTrayMenuTheme);
@@ -109,13 +134,14 @@ public bool Visible
}
///
- /// Stops the activity blink timer and releases the tray icon and all three generated
- /// activity-indicator variants, including the HICONs backing
- /// which alone would leak.
+ /// Stops the activity and high-usage blink timers and releases the tray icon and all four
+ /// generated activity-indicator variants, including the HICONs backing
+ /// which alone would leak.
///
public void Dispose()
{
_timerActivityFlash.Stop();
+ _timerHighUsageBlink.Stop();
_trayIcon.Dispose();
_trayIconNormal.Dispose();
@@ -157,6 +183,53 @@ public void OnActivityDetected(bool isWrite)
});
}
+ ///
+ /// Starts or stops the sustained high-usage warning blink: while active, the tray icon
+ /// repeatedly toggles between the idle icon and the warning indicator every
+ /// until turned off again. Unlike
+ /// 's one-shot flash, this stays on for as long as the
+ /// caller reports the condition is active (see ,
+ /// which aggregates every disk's IsHighUsage state into a single call here).
+ ///
+ /// Whether any disk currently exceeds its high-usage threshold.
+ public void SetHighUsageWarningActive(bool active)
+ {
+ _dispatcher.BeginInvoke(() =>
+ {
+ if (active == _isHighUsageActive)
+ {
+ return;
+ }
+
+ // Record the state change before checking icon readiness below, so a call that
+ // arrives before icon generation finishes doesn't get treated as a no-op — otherwise
+ // a later, genuine call with the same target value would be dropped by the
+ // short-circuit above, permanently losing the warning blink.
+ _isHighUsageActive = active;
+
+ if (_trayActivityIcons[3] == null)
+ {
+ return;
+ }
+
+ if (active)
+ {
+ _blinkOn = true;
+ SetTrayIcon(_trayActivityIcons[3]);
+ _timerHighUsageBlink.Start();
+ }
+ else
+ {
+ _timerHighUsageBlink.Stop();
+
+ if (!_timerActivityFlash.IsEnabled)
+ {
+ SetTrayIcon(_trayActivityIcons[0]);
+ }
+ }
+ });
+ }
+
///
/// Shows a balloon tip from the tray icon.
///
@@ -192,8 +265,11 @@ private void ApplyTrayMenuTheme()
///
/// Generates the tray icon variants indexed by (0 = normal,
- /// 1 = read indicator, 2 = write indicator) by overlaying a colored dot in the top-right
- /// corner of : green for reads, orange for writes. Each generated
+ /// 1 = read indicator, 2 = write indicator, 3 = high-usage warning indicator) by overlaying an
+ /// indicator in the top-right corner of : a green dot for reads, an
+ /// orange dot for writes, and a red exclamation-mark badge (distinct shape, not just a
+ /// differently-colored dot) for the warning state — the shape difference keeps the warning
+ /// blink from being confused with a write flash at 16x16 tray size. Each generated
/// 's backing HICON is recorded in so
/// it can be released via on shutdown, since
/// alone does not release a handle obtained from .
@@ -203,11 +279,14 @@ private void BuildTrayActivityIcons(Icon baseIcon)
var size = baseIcon.Size;
var dotDiameter = Math.Max(4, size.Width / 3);
var dotRect = new Rectangle(size.Width - dotDiameter, 0, dotDiameter, dotDiameter);
+ var badgeDiameter = Math.Max(dotDiameter, size.Height / 2);
+ var badgeRect = new Rectangle(size.Width - badgeDiameter, 0, badgeDiameter, badgeDiameter);
Brush?[] overlayBrushes =
[
null,
new SolidBrush(Color.FromArgb(255, 0, 230, 118)),
new SolidBrush(Color.FromArgb(255, 255, 50, 0)),
+ null,
];
for (var state = 0; state < _trayActivityIcons.Length; state++)
@@ -216,7 +295,11 @@ private void BuildTrayActivityIcons(Icon baseIcon)
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.DrawIcon(baseIcon, new Rectangle(0, 0, size.Width, size.Height));
- if (overlayBrushes[state] is { } brush)
+ if (state == 3)
+ {
+ DrawWarningBadge(graphics, badgeRect);
+ }
+ else if (overlayBrushes[state] is { } brush)
{
graphics.FillEllipse(brush, dotRect);
}
@@ -228,6 +311,40 @@ private void BuildTrayActivityIcons(Icon baseIcon)
}
}
+ ///
+ /// Draws a red exclamation-mark warning badge (filled triangle with a white "!" glyph built
+ /// from two primitives rather than rendered text, so it stays legible at tray-icon scale
+ /// without depending on font hinting/DPI) inside .
+ ///
+ private static void DrawWarningBadge(Graphics graphics, Rectangle bounds)
+ {
+ using var fillBrush = new SolidBrush(Color.FromArgb(255, 230, 0, 0));
+ using var outlinePen = new Pen(Color.FromArgb(255, 90, 0, 0), Math.Max(1f, bounds.Width / 8f));
+ using var glyphBrush = new SolidBrush(Color.White);
+
+ graphics.SmoothingMode = SmoothingMode.AntiAlias;
+
+ System.Drawing.Point[] triangle =
+ [
+ new(bounds.Left + bounds.Width / 2, bounds.Top),
+ new(bounds.Right, bounds.Bottom),
+ new(bounds.Left, bounds.Bottom),
+ ];
+ graphics.FillPolygon(fillBrush, triangle);
+ graphics.DrawPolygon(outlinePen, triangle);
+
+ var stemWidth = Math.Max(1f, bounds.Width / 6f);
+ var stemHeight = bounds.Height * 0.40f;
+ var stemLeft = bounds.Left + (bounds.Width - stemWidth) / 2f;
+ var stemTop = bounds.Top + bounds.Height * 0.38f;
+ graphics.FillRectangle(glyphBrush, stemLeft, stemTop, stemWidth, stemHeight);
+
+ var dotDiameter = stemWidth;
+ var dotLeft = bounds.Left + (bounds.Width - dotDiameter) / 2f;
+ var dotTop = bounds.Top + bounds.Height * 0.82f;
+ graphics.FillEllipse(glyphBrush, dotLeft, dotTop, dotDiameter, dotDiameter);
+ }
+
///
/// Assigns to the tray icon only if it differs from the current one,
/// avoiding redundant reassignment that
diff --git a/src/ManagedDrive.App/Themes/AppTheme.Colors.Dark.xaml b/src/ManagedDrive.App/Themes/AppTheme.Colors.Dark.xaml
index 5171e90..c2a4ec5 100644
--- a/src/ManagedDrive.App/Themes/AppTheme.Colors.Dark.xaml
+++ b/src/ManagedDrive.App/Themes/AppTheme.Colors.Dark.xaml
@@ -16,5 +16,6 @@
+
diff --git a/src/ManagedDrive.App/Themes/AppTheme.Colors.Light.xaml b/src/ManagedDrive.App/Themes/AppTheme.Colors.Light.xaml
index eaf5fc8..20ef4f7 100644
--- a/src/ManagedDrive.App/Themes/AppTheme.Colors.Light.xaml
+++ b/src/ManagedDrive.App/Themes/AppTheme.Colors.Light.xaml
@@ -16,5 +16,6 @@
+
diff --git a/src/ManagedDrive.App/Themes/AppTheme.xaml b/src/ManagedDrive.App/Themes/AppTheme.xaml
index 4232017..3f5b634 100644
--- a/src/ManagedDrive.App/Themes/AppTheme.xaml
+++ b/src/ManagedDrive.App/Themes/AppTheme.xaml
@@ -677,6 +677,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ManagedDrive.App/ViewModels/DiskViewModel.cs b/src/ManagedDrive.App/ViewModels/DiskViewModel.cs
index 830491a..1fa981e 100644
--- a/src/ManagedDrive.App/ViewModels/DiskViewModel.cs
+++ b/src/ManagedDrive.App/ViewModels/DiskViewModel.cs
@@ -22,14 +22,29 @@ public sealed class DiskViewModel : INotifyPropertyChanged, IDisposable
///
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
+ /// rendered on hover — sampling itself is just two array writes per tick, independent of
+ /// whether anyone is looking at it.
+ ///
+ private const int SpeedHistoryLength = 900;
+
private readonly DispatcherTimer _activityThrottleTimer;
private readonly DispatcherTimer _refreshTimer;
+ private readonly double[] _readSpeedHistory = new double[SpeedHistoryLength];
+ private readonly ThroughputTracker _readThroughput = new();
+ private readonly double[] _writeSpeedHistory = new double[SpeedHistoryLength];
+ private readonly ThroughputTracker _writeThroughput = new();
private bool _activityTrackingEnabled;
private ulong _freeBytes;
private bool _isCurrentTempDir;
private DiskActivityEventArgs? _pendingActivity;
+ private double _readBytesPerSecond;
+ private int _speedHistoryHead;
private ulong _usedBytes;
+ private double _writeBytesPerSecond;
///
/// Initializes a new view model for .
@@ -275,6 +290,29 @@ 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.
///
@@ -302,6 +340,20 @@ public void Dispose()
_activityThrottleTimer.Tick -= OnActivityThrottleTick;
SetActivityTrackingEnabled(false);
Disk.SaveFailed -= OnDiskSaveFailed;
+ _readThroughput.Reset();
+ _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;
}
///
@@ -317,6 +369,13 @@ public void Refresh()
_usedBytes = Disk.UsedBytes;
_freeBytes = Disk.FreeBytes;
+ var now = DateTimeOffset.UtcNow;
+ _readBytesPerSecond = _readThroughput.Sample(Disk.TotalBytesRead, now);
+ _writeBytesPerSecond = _writeThroughput.Sample(Disk.TotalBytesWritten, now);
+ _readSpeedHistory[_speedHistoryHead] = _readBytesPerSecond;
+ _writeSpeedHistory[_speedHistoryHead] = _writeBytesPerSecond;
+ _speedHistoryHead = (_speedHistoryHead + 1) % SpeedHistoryLength;
+
var isUiVisible = Application.Current?.MainWindow is { IsVisible: true };
if (isUiVisible)
{
@@ -333,6 +392,10 @@ public void Refresh()
OnPropertyChanged(nameof(SourcePath));
OnPropertyChanged(nameof(HasImagePath));
OnPropertyChanged(nameof(IsPasswordProtected));
+ OnPropertyChanged(nameof(ReadSpeedFormatted));
+ OnPropertyChanged(nameof(ReadSpeedHistory));
+ OnPropertyChanged(nameof(WriteSpeedFormatted));
+ OnPropertyChanged(nameof(WriteSpeedHistory));
IsCurrentTempDir = CheckIsCurrentTempDir();
}
diff --git a/src/ManagedDrive.Cli.Core/ByteFormatter.cs b/src/ManagedDrive.Cli.Core/ByteFormatter.cs
index a5f81f4..32609be 100644
--- a/src/ManagedDrive.Cli.Core/ByteFormatter.cs
+++ b/src/ManagedDrive.Cli.Core/ByteFormatter.cs
@@ -28,4 +28,18 @@ public static string Format(ulong bytes)
return $"{bytes} B";
}
+
+ ///
+ /// Formats a byte-per-second rate using the same unit thresholds as ,
+ /// with an "/s" suffix (e.g. "1.2 MB/s"). Values below 1 B/s are reported as "0 B/s".
+ ///
+ public static string FormatRate(double bytesPerSecond)
+ {
+ if (bytesPerSecond < 1.0)
+ {
+ return "0 B/s";
+ }
+
+ return $"{Format((ulong)Math.Round(bytesPerSecond))}/s";
+ }
}
\ No newline at end of file
diff --git a/src/ManagedDrive.Core/FileSystem/FileContent.cs b/src/ManagedDrive.Core/FileSystem/FileContent.cs
index 88bca24..46851d3 100644
--- a/src/ManagedDrive.Core/FileSystem/FileContent.cs
+++ b/src/ManagedDrive.Core/FileSystem/FileContent.cs
@@ -265,14 +265,18 @@ public void CopyTo(Stream destination, long count)
/// Reads up to bytes from into the content
/// starting at offset 0. The content must already be at least bytes.
/// If the stream ends early, the remaining bytes keep their current (zero) value — mirroring a
- /// bounded copy rather than throwing.
+ /// bounded copy rather than throwing. Returns the number of bytes actually read, so callers
+ /// that need to detect a truncated/corrupt source can compare it against
+ /// themselves.
///
/// The stream to read from.
/// Maximum number of bytes to read.
- public void FillFromStream(Stream source, long count)
+ /// The number of bytes actually filled, which is less than if ended early.
+ public long FillFromStream(Stream source, long count)
{
var remaining = count;
var chunkIndex = 0;
+ var totalFilled = 0L;
while (remaining > 0)
{
@@ -284,15 +288,18 @@ public void FillFromStream(Stream source, long count)
var read = source.Read(_chunks[chunkIndex], filled, segment - filled);
if (read == 0)
{
- return;
+ return totalFilled + filled;
}
filled += read;
}
+ totalFilled += filled;
remaining -= segment;
chunkIndex++;
}
+
+ return totalFilled;
}
///
diff --git a/src/ManagedDrive.Core/FileSystem/MemoryFileSystem.cs b/src/ManagedDrive.Core/FileSystem/MemoryFileSystem.cs
index 948341c..81b8ee2 100644
--- a/src/ManagedDrive.Core/FileSystem/MemoryFileSystem.cs
+++ b/src/ManagedDrive.Core/FileSystem/MemoryFileSystem.cs
@@ -20,6 +20,8 @@ public sealed class MemoryFileSystem : FileSystemBase
private long _lastContentReadTicks;
private ContentAccessInfo? _lastContentWriteAccess;
private long _lastContentWriteTicks;
+ private long _totalBytesRead;
+ private long _totalBytesWritten;
private ulong _maxCapacity;
private string _volumeLabel;
@@ -107,6 +109,18 @@ internal DateTimeOffset? LastContentWriteTimeUtc
}
}
+ ///
+ /// Gets the cumulative number of bytes read from file content since mount. Never resets;
+ /// consumers derive a rate by sampling the delta between two reads of this value over time.
+ ///
+ internal long TotalBytesRead => Interlocked.Read(ref _totalBytesRead);
+
+ ///
+ /// Gets the cumulative number of bytes written to file content since mount. Never resets;
+ /// consumers derive a rate by sampling the delta between two reads of this value over time.
+ ///
+ internal long TotalBytesWritten => Interlocked.Read(ref _totalBytesWritten);
+
///
/// Exposes the underlying node map for serialization and capacity queries.
///
@@ -516,6 +530,7 @@ public override int Read(
{
node.FileData.ReadTo(offset, buffer, toRead);
bytesTransferred = toRead;
+ Interlocked.Add(ref _totalBytesRead, toRead);
var readNow = DateTimeOffset.UtcNow;
Interlocked.Exchange(ref _lastContentReadTicks, readNow.UtcTicks);
Interlocked.Exchange(ref _lastContentReadAccess, new(readNow, node.FilePath));
@@ -773,6 +788,7 @@ public override int Write(
}
bytesTransferred = length;
+ Interlocked.Add(ref _totalBytesWritten, length);
var now = FileTimeNow();
node.FileInfo.LastAccessTime = now;
diff --git a/src/ManagedDrive.Core/Mounting/RamDisk.cs b/src/ManagedDrive.Core/Mounting/RamDisk.cs
index 595a41a..829ce9b 100644
--- a/src/ManagedDrive.Core/Mounting/RamDisk.cs
+++ b/src/ManagedDrive.Core/Mounting/RamDisk.cs
@@ -93,6 +93,18 @@ private RamDisk(MemoryFileSystem fs, FileSystemHost host, DiskOptions options)
///
public DateTimeOffset? LastContentWriteTime => _fs.LastContentWriteTimeUtc;
+ ///
+ /// Gets the cumulative number of bytes read from file content since mount. Never resets;
+ /// consumers derive a rate by sampling the delta over time (see ).
+ ///
+ public long TotalBytesRead => _fs.TotalBytesRead;
+
+ ///
+ /// Gets the cumulative number of bytes written to file content since mount. Never resets;
+ /// consumers derive a rate by sampling the delta over time (see ).
+ ///
+ public long TotalBytesWritten => _fs.TotalBytesWritten;
+
///
/// Gets the UTC timestamp of the most recent successful image save (auto-save, final
/// save on unmount, or manual save via ). null if no
diff --git a/src/ManagedDrive.Core/Mounting/ThroughputTracker.cs b/src/ManagedDrive.Core/Mounting/ThroughputTracker.cs
new file mode 100644
index 0000000..88363cd
--- /dev/null
+++ b/src/ManagedDrive.Core/Mounting/ThroughputTracker.cs
@@ -0,0 +1,47 @@
+namespace ManagedDrive.Core.Mounting;
+
+///
+/// Converts a cumulative byte counter sampled at arbitrary intervals into an instantaneous
+/// bytes/sec rate. Pure logic, independent of WinFsp — the counter and clock are supplied by
+/// the caller on every call.
+///
+public sealed class ThroughputTracker
+{
+ private long? _lastBytes;
+ private DateTimeOffset? _lastSampleTime;
+
+ ///
+ /// Feeds a new cumulative byte total and returns the instantaneous rate in bytes/sec since
+ /// the previous call. The first call for a given instance has no baseline and returns 0.
+ ///
+ public double Sample(long cumulativeBytes, DateTimeOffset now)
+ {
+ if (_lastBytes is not { } lastBytes || _lastSampleTime is not { } lastTime)
+ {
+ _lastBytes = cumulativeBytes;
+ _lastSampleTime = now;
+ return 0.0;
+ }
+
+ var elapsed = (now - lastTime).TotalSeconds;
+ _lastBytes = cumulativeBytes;
+ _lastSampleTime = now;
+
+ if (elapsed <= 0)
+ {
+ return 0.0;
+ }
+
+ var delta = cumulativeBytes - lastBytes;
+ return delta <= 0 ? 0.0 : delta / elapsed;
+ }
+
+ ///
+ /// Discards the current baseline so the next call starts fresh.
+ ///
+ public void Reset()
+ {
+ _lastBytes = null;
+ _lastSampleTime = null;
+ }
+}
diff --git a/src/ManagedDrive.Core/Persistence/ChunkedGcm.cs b/src/ManagedDrive.Core/Persistence/ChunkedGcm.cs
new file mode 100644
index 0000000..6903549
--- /dev/null
+++ b/src/ManagedDrive.Core/Persistence/ChunkedGcm.cs
@@ -0,0 +1,257 @@
+using System.Buffers.Binary;
+using System.Security.Cryptography;
+
+namespace ManagedDrive.Core.Persistence;
+
+///
+/// Shared chunked AES-256-GCM read/write stream pair used to encrypt/decrypt data of any size
+/// without ever holding the whole plaintext or ciphertext in a single buffer —
+/// only exposes single-shot Encrypt/Decrypt over one buffer, and a managed array is
+/// itself practically capped near 2 GB, so this splits the data into independently
+/// encrypted/authenticated chunks instead. Used by both (the
+/// node region of a .mdr image) and (individual
+/// content-addressed file blobs).
+///
+internal static class ChunkedGcm
+{
+ internal const int NonceSize = 12;
+ internal const int TagSize = 16;
+
+ ///
+ /// Size of each independently AES-GCM-encrypted chunk. Kept well under 2 GB so no single
+ /// chunk buffer approaches managed-array or single-shot limits.
+ /// Overridable by tests via to exercise the multi-chunk
+ /// path without allocating a real 64 MB buffer.
+ ///
+ private const int DefaultChunkSize = 64 * 1024 * 1024;
+
+ ///
+ /// Test-only override for ; means use the
+ /// production default. Set via InternalsVisibleTo("ManagedDrive.Tests").
+ ///
+ internal static int? TestChunkSizeOverride;
+
+ internal static int ChunkSize => TestChunkSizeOverride ?? DefaultChunkSize;
+
+ ///
+ /// Derives a unique nonce for chunk from a random per-save
+ /// by XOR-ing its last 4 bytes with the big-endian chunk index.
+ /// This is a standard segmented-AEAD nonce derivation: as long as
+ /// is freshly random per save and chunk indices are never reused within that save (both true
+ /// here — increments a private counter once per chunk), every chunk
+ /// gets a distinct nonce under the same key, which is AES-GCM's only requirement.
+ ///
+ internal static byte[] DeriveChunkNonce(byte[] baseNonce, int chunkIndex)
+ {
+ var nonce = (byte[])baseNonce.Clone();
+ Span indexBytes = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(indexBytes, (uint)chunkIndex);
+
+ for (var i = 0; i < indexBytes.Length; i++)
+ {
+ nonce[NonceSize - indexBytes.Length + i] ^= indexBytes[i];
+ }
+
+ return nonce;
+ }
+
+ ///
+ /// Write-only that buffers up to bytes at a
+ /// time and, on each full buffer plus once more on , AES-256-GCM-encrypts
+ /// that chunk with a nonce derived via and writes it to the
+ /// underlying stream as [Int32 ciphertext length][16-byte tag][ciphertext].
+ ///
+ internal sealed class WriteStream(Stream output, byte[] key, byte[] baseNonce, int chunkSize) : Stream
+ {
+ private readonly byte[] _buffer = new byte[chunkSize];
+ private int _bufferLength;
+ private int _chunkIndex;
+ 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();
+ }
+
+ 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();
+ }
+ }
+ }
+
+ public override void Flush()
+ {
+ }
+
+ ///
+ /// Flushes any partially filled chunk, then writes a final zero-length chunk as an
+ /// explicit end-of-stream marker so the reader knows not to expect another chunk header.
+ /// Must be called exactly once after all plaintext has been written, before disposing.
+ ///
+ public void Complete()
+ {
+ if (_completed)
+ {
+ return;
+ }
+
+ if (_bufferLength > 0)
+ {
+ FlushChunk();
+ }
+
+ WriteChunk(ReadOnlySpan.Empty);
+ _completed = true;
+ }
+
+ private void FlushChunk()
+ {
+ WriteChunk(_buffer.AsSpan(0, _bufferLength));
+ CryptographicOperations.ZeroMemory(_buffer.AsSpan(0, _bufferLength));
+ _bufferLength = 0;
+ }
+
+ private void WriteChunk(ReadOnlySpan plaintext)
+ {
+ var nonce = DeriveChunkNonce(baseNonce, _chunkIndex);
+ var ciphertext = plaintext.Length == 0 ? [] : new byte[plaintext.Length];
+ var tag = new byte[TagSize];
+
+ using (var aesGcm = new AesGcm(key, TagSize))
+ {
+ aesGcm.Encrypt(nonce, plaintext, ciphertext, tag);
+ }
+
+ Span lengthBytes = stackalloc byte[4];
+ BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, ciphertext.Length);
+ output.Write(lengthBytes);
+ output.Write(tag);
+ if (ciphertext.Length > 0)
+ {
+ output.Write(ciphertext);
+ }
+
+ _chunkIndex++;
+ }
+
+ 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();
+ }
+
+ ///
+ /// Read-only counterpart to : reads the
+ /// [length][tag][ciphertext] chunk sequence from the underlying stream, decrypting
+ /// each chunk with the matching derived nonce and exposing the concatenated plaintext as a
+ /// normal readable stream (typically wrapped by a decompressing ).
+ /// Throws if any chunk's tag fails to authenticate —
+ /// callers should translate that into their own password-incorrect exception type.
+ ///
+ internal sealed class ReadStream(Stream source, byte[] key, byte[] baseNonce) : Stream
+ {
+ private byte[] _currentChunk = [];
+ private int _currentChunkLength;
+ private int _positionInChunk;
+ private int _chunkIndex;
+ private bool _endOfStream;
+
+ 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 int Read(byte[] buffer, int offset, int count)
+ {
+ var totalRead = 0;
+
+ while (count > 0)
+ {
+ if (_positionInChunk == _currentChunkLength)
+ {
+ if (_endOfStream || !TryReadNextChunk())
+ {
+ break;
+ }
+ }
+
+ var toCopy = Math.Min(count, _currentChunkLength - _positionInChunk);
+ Array.Copy(_currentChunk, _positionInChunk, buffer, offset, toCopy);
+ _positionInChunk += toCopy;
+ offset += toCopy;
+ count -= toCopy;
+ totalRead += toCopy;
+ }
+
+ return totalRead;
+ }
+
+ private bool TryReadNextChunk()
+ {
+ Span lengthBytes = stackalloc byte[4];
+ source.ReadExactly(lengthBytes);
+ var ciphertextLength = BinaryPrimitives.ReadInt32LittleEndian(lengthBytes);
+
+ var tag = new byte[TagSize];
+ source.ReadExactly(tag);
+
+ var ciphertext = ciphertextLength == 0 ? [] : new byte[ciphertextLength];
+ if (ciphertextLength > 0)
+ {
+ source.ReadExactly(ciphertext);
+ }
+
+ var nonce = DeriveChunkNonce(baseNonce, _chunkIndex);
+ var plaintext = ciphertextLength == 0 ? [] : new byte[ciphertextLength];
+ using (var aesGcm = new AesGcm(key, TagSize))
+ {
+ aesGcm.Decrypt(nonce, ciphertext, tag, plaintext);
+ }
+
+ _chunkIndex++;
+
+ if (ciphertextLength == 0)
+ {
+ _endOfStream = true;
+ return false;
+ }
+
+ _currentChunk = plaintext;
+ _currentChunkLength = plaintext.Length;
+ _positionInChunk = 0;
+ return true;
+ }
+
+ public override void Flush() => throw new NotSupportedException();
+
+ public override void Write(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();
+ }
+}
diff --git a/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs b/src/ManagedDrive.Core/Persistence/DiskImageSerializer.cs
index e01fbbf..95392b1 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 3)
+/// - Int32 version (currently 4)
/// - 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)
@@ -31,10 +31,23 @@ namespace ManagedDrive.Core.Persistence;
/// wraps this randomly generated CEK; actual data is always encrypted with the CEK, so
/// changing the password never requires re-encrypting existing data.
///
-/// - When encrypted: data nonce (12), data tag (16) for the node region below.
-/// - The node region, gzip-compressed whenever the level is not , then AES-256-GCM encrypted with the CEK when the image is encrypted:
-/// - Int32 node count
-/// - For each node: path, metadata, security descriptor bytes, file data bytes
+/// -
+/// Version 3 (legacy, still readable): data nonce (12), data tag (16), then the entire
+/// remaining file is one AES-256-GCM ciphertext blob wrapping the whole gzip-compressed
+/// node region. Requires materializing the whole node region as a single byte array on
+/// both save and load, which is capped by 's single-shot API and by
+/// 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
+/// 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 ).
+///
+/// - 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
@@ -44,7 +57,7 @@ public static class DiskImageSerializer
private const int Pbkdf2Iterations = 210_000;
private const int SaltSize = 16;
private const int TagSize = 16;
- private const int Version = 3;
+ private const int Version = 4;
private static readonly byte[] Magic = "MDRD"u8.ToArray();
///
@@ -96,7 +109,7 @@ public static FileNodeMap Load(
return version <= 2
? LoadLegacy(stream, reader, version, level, out capacityBytes, out volumeLabel)
- : LoadV3(stream, reader, level, isEncrypted, password, out capacityBytes, out volumeLabel, out cek);
+ : LoadCurrent(stream, reader, version, level, isEncrypted, password, out capacityBytes, out volumeLabel, out cek);
}
///
@@ -136,7 +149,7 @@ public static void PeekHeader(
}
else
{
- // Version 3 layout: capacity/label are always plaintext header fields.
+ // Version 3+ layout: capacity/label are always plaintext header fields.
capacityBytes = reader.ReadUInt64();
volumeLabel = reader.ReadString();
}
@@ -157,8 +170,8 @@ public static void PeekHeader(
///
///
/// Optional progress reporter, updated with a fraction in [0, 1] as each node is written.
- /// The subsequent gzip compression and AES-GCM encryption steps operate on the whole
- /// serialized buffer as a single unit and are not individually reported.
+ /// The subsequent gzip compression and (when encrypting) AES-256-GCM chunk encryption happen
+ /// as nodes stream through and are not individually reported.
///
public static void Save(
FileNodeMap nodeMap,
@@ -176,41 +189,6 @@ public static void Save(
Directory.CreateDirectory(directory);
}
- // Serialize the node region into memory first: it needs to be a single contiguous byte
- // array to gzip-compress and then AES-GCM-encrypt as one unit (the node map is already
- // fully materialized in memory anyway, so this adds no meaningful overhead).
- byte[] nodeRegionBytes;
- using (var nodeRegionStream = new MemoryStream())
- {
- using (var payloadWriter = new BinaryWriter(
- compress ? new GZipStream(nodeRegionStream, ToCompressionLevel(level), leaveOpen: true) : nodeRegionStream,
- System.Text.Encoding.UTF8,
- leaveOpen: true))
- {
- var nodes = nodeMap.GetAllNodes();
- payloadWriter.Write(nodes.Count);
-
- if (nodes.Count == 0)
- {
- progress?.Report(1.0);
- }
- else
- {
- var written = 0;
- foreach (var kvp in nodes)
- {
- WriteNode(payloadWriter, kvp.Key, kvp.Value);
- written++;
- progress?.Report((double)written / nodes.Count);
- }
- }
-
- payloadWriter.Flush();
- }
-
- nodeRegionBytes = nodeRegionStream.ToArray();
- }
-
// Write to a sibling temp file and flush it to disk before atomically replacing the
// real image path, so a process kill mid-write (e.g. during a Windows shutdown) never
// leaves the actual image truncated — worst case is a stray .tmp file.
@@ -240,24 +218,21 @@ public static void Save(
writer.Write(wrapTag);
writer.Write(wrappedCek);
- var dataNonce = RandomNumberGenerator.GetBytes(NonceSize);
- var ciphertext = new byte[nodeRegionBytes.Length];
- var dataTag = new byte[TagSize];
- using (var aesGcm = new AesGcm(enc.Cek, TagSize))
- {
- aesGcm.Encrypt(dataNonce, nodeRegionBytes, ciphertext, dataTag);
- }
-
- writer.Write(dataNonce);
- writer.Write(dataTag);
- writer.Write(ciphertext);
+ var baseNonce = RandomNumberGenerator.GetBytes(NonceSize);
+ writer.Write(baseNonce);
+ writer.Flush();
+
+ // 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);
+ chunkedStream.Complete();
}
else
{
- writer.Write(nodeRegionBytes);
+ writer.Flush();
+ WriteNodeRegion(stream, compress, level, nodeMap, progress);
}
-
- writer.Flush();
}
stream.Flush(flushToDisk: true);
@@ -278,9 +253,59 @@ public static void Save(
throw;
}
+ }
+
+ ///
+ /// 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
+ /// relying on 's own disposal with leaveOpen: true, which
+ /// would skip it — so the deflate stream's final block/trailer is always flushed before
+ /// is used for anything else.
+ ///
+ private static void WriteNodeRegion(
+ Stream target,
+ bool compress,
+ ImageCompressionLevel level,
+ FileNodeMap nodeMap,
+ IProgress? progress)
+ {
+ var payloadStream = compress
+ ? new GZipStream(target, ToCompressionLevel(level), leaveOpen: true)
+ : target;
+
+ try
+ {
+ using var payloadWriter = new BinaryWriter(payloadStream, System.Text.Encoding.UTF8, leaveOpen: true);
+
+ var nodes = nodeMap.GetAllNodes();
+ payloadWriter.Write(nodes.Count);
+
+ if (nodes.Count == 0)
+ {
+ progress?.Report(1.0);
+ }
+ else
+ {
+ var written = 0;
+ foreach (var kvp in nodes)
+ {
+ WriteNode(payloadWriter, kvp.Key, kvp.Value);
+ written++;
+ progress?.Report((double)written / nodes.Count);
+ }
+ }
+
+ payloadWriter.Flush();
+ }
finally
{
- CryptographicOperations.ZeroMemory(nodeRegionBytes);
+ if (compress)
+ {
+ payloadStream.Dispose();
+ }
}
}
@@ -309,13 +334,17 @@ private static FileNodeMap LoadLegacy(
}
///
- /// Reads a version 3 image: capacity/label are always plaintext header fields; the node
- /// region (from node count onward) is compressed and, when encrypted, additionally wrapped
- /// in AES-256-GCM using the content-encryption key unwrapped from the password.
+ /// 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
+ /// AES-256-GCM using the content-encryption key unwrapped from the password. Version 3 wraps
+ /// the whole node region as one ciphertext blob (legacy, kept only for backward compatibility);
+ /// version 4 uses independently encrypted chunks so no single buffer needs to hold the entire
+ /// node region — see the class remarks and .
///
- private static FileNodeMap LoadV3(
+ private static FileNodeMap LoadCurrent(
FileStream stream,
BinaryReader reader,
+ int version,
ImageCompressionLevel level,
bool isEncrypted,
string? password,
@@ -326,65 +355,116 @@ private static FileNodeMap LoadV3(
capacityBytes = reader.ReadUInt64();
volumeLabel = reader.ReadString();
cek = null;
+ var compressed = level != ImageCompressionLevel.None;
- byte[] nodeRegionBytes;
+ 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);
+ }
- if (isEncrypted)
+ if (password is null)
{
- if (password is null)
- {
- throw new ImagePasswordRequiredException();
- }
+ throw new ImagePasswordRequiredException();
+ }
- var salt = reader.ReadBytes(SaltSize);
- var iterations = reader.ReadInt32();
- var wrapNonce = reader.ReadBytes(NonceSize);
- var wrapTag = reader.ReadBytes(TagSize);
- var wrappedCek = reader.ReadBytes(CekSize);
+ var salt = reader.ReadBytes(SaltSize);
+ var iterations = reader.ReadInt32();
+ var wrapNonce = reader.ReadBytes(NonceSize);
+ var wrapTag = reader.ReadBytes(TagSize);
+ var wrappedCek = reader.ReadBytes(CekSize);
- var resolvedCek = UnwrapCek(wrappedCek, password, salt, iterations, wrapNonce, wrapTag);
- cek = resolvedCek;
+ var resolvedCek = UnwrapCek(wrappedCek, password, salt, iterations, wrapNonce, wrapTag);
+ cek = resolvedCek;
- var dataNonce = reader.ReadBytes(NonceSize);
- var dataTag = reader.ReadBytes(TagSize);
- var ciphertext = reader.ReadBytes((int)(stream.Length - stream.Position));
+ return version switch
+ {
+ 3 => LoadLegacyEncryptedBlob(stream, reader, resolvedCek, compressed),
+ 4 => LoadChunkedEncrypted(stream, reader, resolvedCek, compressed),
+ _ => throw new InvalidDataException($"Unsupported image version: {version}."),
+ };
+ }
- var plaintext = new byte[ciphertext.Length];
- try
- {
- using var aesGcm = new AesGcm(resolvedCek, TagSize);
- aesGcm.Decrypt(dataNonce, ciphertext, dataTag, plaintext);
- }
- catch (CryptographicException)
- {
- throw new ImagePasswordIncorrectException();
- }
+ ///
+ /// Version 3's whole-blob encrypted node region: a single AES-256-GCM ciphertext covering the
+ /// entire (already gzip-compressed) node region. Requires materializing the whole region as
+ /// one byte array, which is what version 4 exists to avoid — kept only so pre-existing images
+ /// keep loading.
+ ///
+ private static FileNodeMap LoadLegacyEncryptedBlob(
+ FileStream stream,
+ BinaryReader reader,
+ byte[] cek,
+ bool compressed)
+ {
+ var dataNonce = reader.ReadBytes(NonceSize);
+ var dataTag = reader.ReadBytes(TagSize);
+ var ciphertext = reader.ReadBytes((int)(stream.Length - stream.Position));
- nodeRegionBytes = plaintext;
+ var plaintext = new byte[ciphertext.Length];
+ try
+ {
+ using var aesGcm = new AesGcm(cek, TagSize);
+ aesGcm.Decrypt(dataNonce, ciphertext, dataTag, plaintext);
}
- else
+ catch (CryptographicException)
{
- nodeRegionBytes = reader.ReadBytes((int)(stream.Length - stream.Position));
+ throw new ImagePasswordIncorrectException();
}
try
{
- var compressed = level != ImageCompressionLevel.None;
- using var nodeRegionStream = new MemoryStream(nodeRegionBytes);
- using var payloadReader = new BinaryReader(
- compressed
- ? new GZipStream(nodeRegionStream, CompressionMode.Decompress, leaveOpen: true)
- : nodeRegionStream,
- System.Text.Encoding.UTF8);
-
- return ReadNodes(payloadReader);
+ using var nodeRegionStream = new MemoryStream(plaintext, writable: false);
+ return ReadNodeRegion(nodeRegionStream, compressed);
}
finally
{
- CryptographicOperations.ZeroMemory(nodeRegionBytes);
+ CryptographicOperations.ZeroMemory(plaintext);
+ }
+ }
+
+ ///
+ /// Version 4'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.
+ ///
+ private static FileNodeMap LoadChunkedEncrypted(
+ FileStream stream,
+ BinaryReader reader,
+ byte[] cek,
+ bool compressed)
+ {
+ var baseNonce = reader.ReadBytes(NonceSize);
+
+ try
+ {
+ using var chunkedStream = new ChunkedGcm.ReadStream(stream, cek, baseNonce);
+ return ReadNodeRegion(chunkedStream, compressed);
+ }
+ catch (CryptographicException)
+ {
+ throw new ImagePasswordIncorrectException();
}
}
+ ///
+ /// Reads the node-count-prefixed node region from , transparently
+ /// gzip-decompressing when is set. Mirrors .
+ ///
+ private static FileNodeMap ReadNodeRegion(Stream source, bool compressed)
+ {
+ using var payloadReader = new BinaryReader(
+ compressed
+ ? new GZipStream(source, CompressionMode.Decompress, leaveOpen: true)
+ : source,
+ System.Text.Encoding.UTF8,
+ leaveOpen: true);
+
+ return ReadNodes(payloadReader);
+ }
+
private static void ReadHeader(
BinaryReader reader,
out int version,
@@ -398,7 +478,7 @@ private static void ReadHeader(
}
version = reader.ReadInt32();
- if (version is not (1 or 2 or 3))
+ if (version is not (1 or 2 or 3 or 4))
{
throw new InvalidDataException($"Unsupported image version: {version}.");
}
diff --git a/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs b/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs
index 525b51b..1fc0994 100644
--- a/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs
+++ b/src/ManagedDrive.Core/Snapshots/SnapshotStore.cs
@@ -13,8 +13,17 @@ namespace ManagedDrive.Core.Snapshots;
///
internal static class SnapshotStore
{
- private const int BlobFlagCompressed = 0b01;
- private const int BlobFlagEncrypted = 0b10;
+ private const int BlobFlagCompressed = 0b001;
+ private const int BlobFlagEncrypted = 0b010;
+
+ ///
+ /// Marks a blob's encrypted payload as chunked AES-256-GCM (see )
+ /// rather than the legacy whole-blob single-shot layout. Only meaningful when
+ /// is also set. Never set for blobs written before this
+ /// flag existed, so old blobs keep loading via the legacy branch in .
+ ///
+ private const int BlobFlagChunked = 0b100;
+
private const int BlobNonceSize = 12;
private const int BlobTagSize = 16;
private const int Version = 1;
@@ -241,7 +250,16 @@ internal static void Write(
}
}
- private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileContent data, int length, ImageCompressionLevel level, byte[]? cek)
+ ///
+ /// Writes the blob for if it doesn't already exist. Streams
+ /// straight from through gzip 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.
+ ///
+ private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileContent data, long length, ImageCompressionLevel level, byte[]? cek)
{
var blobPath = HashToBlobPath(blobDirectory, hash);
if (File.Exists(blobPath))
@@ -252,38 +270,7 @@ private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileCon
Directory.CreateDirectory(Path.GetDirectoryName(blobPath)!);
var compress = level != ImageCompressionLevel.None;
-
- byte[] payload;
- if (compress)
- {
- using var ms = new MemoryStream();
- using (var gzip = new GZipStream(ms, ToCompressionLevel(level), leaveOpen: true))
- {
- data.CopyTo(gzip, length);
- }
-
- payload = ms.ToArray();
- }
- else
- {
- payload = data.ToArray(length);
- }
-
- var flag = compress ? BlobFlagCompressed : 0;
- byte[]? nonce = null;
- byte[]? tag = null;
-
- if (cek is not null)
- {
- flag |= BlobFlagEncrypted;
- nonce = RandomNumberGenerator.GetBytes(BlobNonceSize);
- var ciphertext = new byte[payload.Length];
- var localTag = new byte[BlobTagSize];
- using var aesGcm = new AesGcm(cek, BlobTagSize);
- aesGcm.Encrypt(nonce, payload, ciphertext, localTag);
- tag = localTag;
- payload = ciphertext;
- }
+ var flag = (compress ? BlobFlagCompressed : 0) | (cek is not null ? BlobFlagEncrypted | BlobFlagChunked : 0);
var tempPath = blobPath + "." + Guid.NewGuid().ToString("N") + ".tmp";
@@ -293,13 +280,37 @@ private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileCon
{
stream.WriteByte((byte)flag);
- if (nonce is not null && tag is not null)
+ Stream target = stream;
+ ChunkedGcm.WriteStream? chunkedStream = null;
+ if (cek is not null)
+ {
+ var baseNonce = RandomNumberGenerator.GetBytes(BlobNonceSize);
+ stream.Write(baseNonce);
+ chunkedStream = new ChunkedGcm.WriteStream(stream, cek, baseNonce, ChunkedGcm.ChunkSize);
+ target = chunkedStream;
+ }
+
+ if (compress)
+ {
+ var gzip = new GZipStream(target, ToCompressionLevel(level), leaveOpen: true);
+ try
+ {
+ data.CopyTo(gzip, 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();
+ }
+ }
+ else
{
- stream.Write(nonce);
- stream.Write(tag);
+ data.CopyTo(target, length);
}
- stream.Write(payload);
+ chunkedStream?.Complete();
stream.Flush(flushToDisk: true);
}
@@ -320,6 +331,12 @@ private static void EnsureBlobWritten(string blobDirectory, byte[] hash, FileCon
}
}
+ ///
+ /// Reads the blob for straight into a via
+ /// , decrypting (chunked or legacy whole-blob, see
+ /// ) and decompressing on the fly rather than materializing the
+ /// ciphertext, plaintext, and decompressed bytes as three separate whole-file buffers.
+ ///
private static FileContent ReadBlob(string blobDirectory, byte[] hash, string nodePath, ulong fileSize, ulong allocationSize, byte[]? cek)
{
var blobPath = HashToBlobPath(blobDirectory, hash);
@@ -333,67 +350,94 @@ private static FileContent ReadBlob(string blobDirectory, byte[] hash, string no
var flag = stream.ReadByte();
var compressed = (flag & BlobFlagCompressed) != 0;
var encrypted = (flag & BlobFlagEncrypted) != 0;
+ var chunked = (flag & BlobFlagChunked) != 0;
- byte[] payload;
- if (encrypted)
+ Stream plaintextStream;
+ byte[]? legacyPlaintext = null;
+
+ if (!encrypted)
+ {
+ plaintextStream = stream;
+ }
+ else
{
if (cek is null)
{
throw new ImagePasswordRequiredException();
}
- var nonce = new byte[BlobNonceSize];
- stream.ReadExactly(nonce);
- var tag = new byte[BlobTagSize];
- stream.ReadExactly(tag);
-
- using var cipherStream = new MemoryStream();
- stream.CopyTo(cipherStream);
- var ciphertext = cipherStream.ToArray();
-
- var plaintext = new byte[ciphertext.Length];
- try
+ if (chunked)
{
- using var aesGcm = new AesGcm(cek, BlobTagSize);
- aesGcm.Decrypt(nonce, ciphertext, tag, plaintext);
+ var baseNonce = new byte[BlobNonceSize];
+ stream.ReadExactly(baseNonce);
+ plaintextStream = new ChunkedGcm.ReadStream(stream, cek, baseNonce);
}
- catch (CryptographicException)
+ else
{
- throw new ImagePasswordIncorrectException();
- }
+ // Legacy whole-blob layout: a single AES-256-GCM ciphertext covering the entire
+ // (already gzip-compressed) payload. Kept only so pre-existing blobs keep loading.
+ var nonce = new byte[BlobNonceSize];
+ stream.ReadExactly(nonce);
+ var tag = new byte[BlobTagSize];
+ stream.ReadExactly(tag);
+
+ using var cipherStream = new MemoryStream();
+ stream.CopyTo(cipherStream);
+ var ciphertext = cipherStream.ToArray();
+
+ var plaintext = new byte[ciphertext.Length];
+ try
+ {
+ using var aesGcm = new AesGcm(cek, BlobTagSize);
+ aesGcm.Decrypt(nonce, ciphertext, tag, plaintext);
+ }
+ catch (CryptographicException)
+ {
+ throw new ImagePasswordIncorrectException();
+ }
- payload = plaintext;
+ legacyPlaintext = plaintext;
+ plaintextStream = new MemoryStream(plaintext, writable: false);
+ }
}
- else
+
+ var sourceStream = compressed
+ ? new GZipStream(plaintextStream, CompressionMode.Decompress)
+ : plaintextStream;
+
+ var aligned = FileNode.AlignToAllocationUnit(allocationSize);
+ var content = FileContent.CreateZeroed(aligned);
+ long filled;
+
+ try
{
- using var raw = new MemoryStream();
- stream.CopyTo(raw);
- payload = raw.ToArray();
+ filled = content.FillFromStream(sourceStream, (long)fileSize);
}
-
- byte[] content;
- if (compressed)
+ catch (CryptographicException)
{
- using var payloadStream = new MemoryStream(payload);
- using var gzip = new GZipStream(payloadStream, CompressionMode.Decompress);
- using var decompressed = new MemoryStream();
- gzip.CopyTo(decompressed);
- content = decompressed.ToArray();
+ throw new ImagePasswordIncorrectException();
}
- else
+ finally
{
- content = payload;
+ if (compressed)
+ {
+ sourceStream.Dispose();
+ }
+
+ if (legacyPlaintext is not null)
+ {
+ CryptographicOperations.ZeroMemory(legacyPlaintext);
+ }
}
- if ((ulong)content.Length != fileSize)
+ if ((ulong)filled != fileSize)
{
throw new InvalidDataException(
$"Snapshot blob for '{nodePath}' (hash {Convert.ToHexStringLower(hash)}) has unexpected length " +
- $"{content.Length} bytes; expected {fileSize}. The snapshot may be corrupted.");
+ $"{filled} bytes; expected {fileSize}. The snapshot may be corrupted.");
}
- var aligned = FileNode.AlignToAllocationUnit(allocationSize);
- return FileContent.FromSpan(content, aligned);
+ return content;
}
private static void ReadHeader(BinaryReader reader)
@@ -499,7 +543,7 @@ private static void WriteNode(BinaryWriter writer, string path, FileNode node, s
return;
}
- var fileSize = (int)Math.Min(node.FileInfo.FileSize, (ulong)node.FileData.Length);
+ var fileSize = (long)Math.Min(node.FileInfo.FileSize, (ulong)node.FileData.Length);
byte[] hash;
using (var incrementalHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256))
diff --git a/tests/ManagedDrive.Tests/ByteFormatterTests.cs b/tests/ManagedDrive.Tests/ByteFormatterTests.cs
new file mode 100644
index 0000000..1c76019
--- /dev/null
+++ b/tests/ManagedDrive.Tests/ByteFormatterTests.cs
@@ -0,0 +1,20 @@
+using ManagedDrive.Cli.Core;
+
+namespace ManagedDrive.Tests;
+
+public sealed class ByteFormatterTests
+{
+ [Theory]
+ [InlineData(0.0, "0 B/s")]
+ [InlineData(0.5, "0 B/s")]
+ [InlineData(512.0, "512 B/s")]
+ [InlineData(1024.0 * 5, "5.0 KB/s")]
+ [InlineData(1024.0 * 1024 * 3, "3.0 MB/s")]
+ [InlineData(1024.0 * 1024 * 1024 * 2, "2.0 GB/s")]
+ public void FormatRate_UsesSameUnitThresholdsAsFormat(double bytesPerSecond, string expected)
+ {
+ var formatted = ByteFormatter.FormatRate(bytesPerSecond);
+
+ Assert.Equal(expected, formatted);
+ }
+}
diff --git a/tests/ManagedDrive.Tests/DiskImageSerializerTests.cs b/tests/ManagedDrive.Tests/DiskImageSerializerTests.cs
index 76e9dff..e8c30ab 100644
--- a/tests/ManagedDrive.Tests/DiskImageSerializerTests.cs
+++ b/tests/ManagedDrive.Tests/DiskImageSerializerTests.cs
@@ -1,3 +1,6 @@
+using System.IO.Compression;
+using System.Security.Cryptography;
+
namespace ManagedDrive.Tests;
public sealed class DiskImageSerializerTests
@@ -251,6 +254,147 @@ public void SaveThenLoad_WithPassword_RoundTrips(ImageCompressionLevel level)
}
}
+ [Theory]
+ [InlineData(ImageCompressionLevel.None)]
+ [InlineData(ImageCompressionLevel.Fastest)]
+ public void SaveThenLoad_WithPasswordAcrossMultipleChunks_RoundTrips(ImageCompressionLevel level)
+ {
+ // Force a tiny chunk size so a handful of KB of node data spans several chunks,
+ // exercising the version-4 chunked AES-GCM path without allocating a real 64 MB buffer.
+ ChunkedGcm.TestChunkSizeOverride = 64;
+ var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mdr");
+ try
+ {
+ var map = new FileNodeMap();
+ map.Add("\\", MakeDir());
+ for (var i = 0; i < 20; i++)
+ {
+ map.Add($"\\File{i}.txt", MakeFile(System.Text.Encoding.UTF8.GetBytes($"content for file number {i}")));
+ }
+
+ var cek = DiskImageSerializer.GenerateCek();
+ DiskImageSerializer.Save(map, capacityBytes: 1024 * 1024, "ChunkedLabel", path, level, new ImageEncryptionInfo("s3cret", cek));
+
+ var loaded = DiskImageSerializer.Load(path, out var capacityBytes, out var volumeLabel, "s3cret", out var loadedCek);
+
+ Assert.Equal(1024UL * 1024, capacityBytes);
+ Assert.Equal("ChunkedLabel", volumeLabel);
+ Assert.Equal(21, loaded.Count);
+ for (var i = 0; i < 20; 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.Equal(cek, loadedCek);
+ }
+ finally
+ {
+ ChunkedGcm.TestChunkSizeOverride = null;
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void SaveThenLoad_WithWrongPasswordAcrossMultipleChunks_ThrowsPasswordIncorrect()
+ {
+ ChunkedGcm.TestChunkSizeOverride = 64;
+ var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mdr");
+ try
+ {
+ var map = new FileNodeMap();
+ map.Add("\\", MakeDir());
+ for (var i = 0; i < 20; i++)
+ {
+ map.Add($"\\File{i}.txt", MakeFile("hello world"u8.ToArray()));
+ }
+
+ DiskImageSerializer.Save(map, capacityBytes: 1024 * 1024, "MyLabel", path, ImageCompressionLevel.Fastest,
+ new ImageEncryptionInfo("s3cret", DiskImageSerializer.GenerateCek()));
+
+ Assert.Throws(() =>
+ DiskImageSerializer.Load(path, out _, out _, "wrong-password", out _));
+ }
+ finally
+ {
+ ChunkedGcm.TestChunkSizeOverride = null;
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void Load_LegacyVersion3EncryptedWholeBlobImage_StillLoads()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.mdr");
+ try
+ {
+ const string password = "s3cret";
+ const int iterations = 210_000;
+ var cek = DiskImageSerializer.GenerateCek();
+
+ 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();
+ }
+
+ var salt = RandomNumberGenerator.GetBytes(16);
+ var kek = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, 32);
+ var wrapNonce = RandomNumberGenerator.GetBytes(12);
+ var wrappedCek = new byte[cek.Length];
+ var wrapTag = new byte[16];
+ using (var aesGcm = new AesGcm(kek, 16))
+ {
+ aesGcm.Encrypt(wrapNonce, cek, wrappedCek, wrapTag);
+ }
+
+ var dataNonce = RandomNumberGenerator.GetBytes(12);
+ var ciphertext = new byte[nodeRegion.Length];
+ var dataTag = new byte[16];
+ using (var aesGcm = new AesGcm(cek, 16))
+ {
+ aesGcm.Encrypt(dataNonce, nodeRegion, ciphertext, dataTag);
+ }
+
+ 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(3); // legacy whole-blob encrypted version
+ writer.Write((byte)ImageCompressionLevel.Fastest);
+ writer.Write((byte)1); // isEncrypted
+ writer.Write(2048UL);
+ writer.Write("LegacyEncryptedLabel");
+ writer.Write(salt);
+ writer.Write(iterations);
+ writer.Write(wrapNonce);
+ writer.Write(wrapTag);
+ writer.Write(wrappedCek);
+ writer.Write(dataNonce);
+ writer.Write(dataTag);
+ writer.Write(ciphertext);
+ }
+
+ var loaded = DiskImageSerializer.Load(path, out var capacityBytes, out var volumeLabel, password, out var loadedCek);
+
+ Assert.Equal(2048UL, capacityBytes);
+ Assert.Equal("LegacyEncryptedLabel", volumeLabel);
+ Assert.Equal(0, loaded.Count);
+ Assert.Equal(cek, loadedCek);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
private static FileNode MakeDir() => new()
{
FileInfo = { FileAttributes = (uint)FileAttributes.Directory },
diff --git a/tests/ManagedDrive.Tests/ManagedDrive.Tests.csproj b/tests/ManagedDrive.Tests/ManagedDrive.Tests.csproj
index 6201e0a..447290c 100644
--- a/tests/ManagedDrive.Tests/ManagedDrive.Tests.csproj
+++ b/tests/ManagedDrive.Tests/ManagedDrive.Tests.csproj
@@ -22,6 +22,7 @@
+
diff --git a/tests/ManagedDrive.Tests/SnapshotManagerTests.cs b/tests/ManagedDrive.Tests/SnapshotManagerTests.cs
index a5c15a6..7054924 100644
--- a/tests/ManagedDrive.Tests/SnapshotManagerTests.cs
+++ b/tests/ManagedDrive.Tests/SnapshotManagerTests.cs
@@ -1,3 +1,5 @@
+using System.Security.Cryptography;
+
namespace ManagedDrive.Tests;
public sealed class SnapshotManagerTests : IDisposable
@@ -315,6 +317,119 @@ public void LoadSnapshot_EncryptedBlobWithWrongCek_ThrowsPasswordIncorrect()
SnapshotManager.LoadSnapshot(snapshot.Path, out _, out _, wrongCek));
}
+ [Fact]
+ public void WriteSnapshot_EncryptedAcrossMultipleChunks_RoundTrips()
+ {
+ // Force a tiny chunk size so a small file's blob spans several chunks, exercising the
+ // chunked AES-GCM blob path without allocating a real 64 MB buffer.
+ ChunkedGcm.TestChunkSizeOverride = 64;
+ try
+ {
+ var cek = DiskImageSerializer.GenerateCek();
+ var content = System.Text.Encoding.UTF8.GetBytes(string.Concat(Enumerable.Range(0, 20).Select(i => $"line {i} of content;")));
+
+ 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, cek);
+
+ var snapshot = Assert.Single(SnapshotManager.ListSnapshots(_mainImagePath));
+ var loaded = SnapshotManager.LoadSnapshot(snapshot.Path, out _, out _, cek);
+
+ Assert.True(loaded.TryGet("\\a.txt", out var node));
+ Assert.Equal(content, node!.FileData!.ToArray(content.Length));
+ }
+ finally
+ {
+ ChunkedGcm.TestChunkSizeOverride = null;
+ }
+ }
+
+ [Fact]
+ public void LoadSnapshot_EncryptedAcrossMultipleChunksWithWrongCek_ThrowsPasswordIncorrect()
+ {
+ ChunkedGcm.TestChunkSizeOverride = 64;
+ try
+ {
+ var cek = DiskImageSerializer.GenerateCek();
+ var wrongCek = DiskImageSerializer.GenerateCek();
+ var content = System.Text.Encoding.UTF8.GetBytes(string.Concat(Enumerable.Range(0, 20).Select(i => $"line {i} of content;")));
+
+ 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, cek);
+
+ var snapshot = Assert.Single(SnapshotManager.ListSnapshots(_mainImagePath));
+
+ Assert.Throws(() =>
+ SnapshotManager.LoadSnapshot(snapshot.Path, out _, out _, wrongCek));
+ }
+ finally
+ {
+ ChunkedGcm.TestChunkSizeOverride = null;
+ }
+ }
+
+ [Fact]
+ public void LoadSnapshot_LegacyWholeBlobEncryptedBlob_StillLoads()
+ {
+ var cek = DiskImageSerializer.GenerateCek();
+ var content = new byte[] { 7, 7, 7 };
+
+ // Write a normal encrypted snapshot first so the index and blob directory exist with the
+ // expected layout, then hand-overwrite the blob it produced in the pre-chunking format
+ // (flag without the chunked bit; nonce+tag+whole ciphertext) to simulate a blob written
+ // before BlobFlagChunked 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.None, cek);
+
+ var blobPath = Directory.EnumerateFiles(BlobDirectory, "*.blob", SearchOption.AllDirectories).Single();
+
+ var nonce = RandomNumberGenerator.GetBytes(12);
+ var ciphertext = new byte[content.Length];
+ var tag = new byte[16];
+ using (var aesGcm = new AesGcm(cek, 16))
+ {
+ aesGcm.Encrypt(nonce, content, ciphertext, tag);
+ }
+
+ using (var stream = new FileStream(blobPath, FileMode.Create, FileAccess.Write))
+ {
+ stream.WriteByte(0b010); // Encrypted, not Compressed, not Chunked
+ stream.Write(nonce);
+ stream.Write(tag);
+ stream.Write(ciphertext);
+ }
+
+ var snapshot = Assert.Single(SnapshotManager.ListSnapshots(_mainImagePath));
+ var loaded = SnapshotManager.LoadSnapshot(snapshot.Path, out _, out _, cek);
+
+ Assert.True(loaded.TryGet("\\a.txt", out var node));
+ Assert.Equal(content, node!.FileData!.ToArray(content.Length));
+ }
+
+ [Fact]
+ public void LoadSnapshot_TruncatedBlob_ThrowsInvalidData()
+ {
+ WriteSnapshotWithFile(new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), "\\a.txt", [1, 2, 3, 4, 5]);
+
+ var snapshot = Assert.Single(SnapshotManager.ListSnapshots(_mainImagePath));
+ var blobFile = Directory.EnumerateFiles(BlobDirectory, "*.blob", SearchOption.AllDirectories).Single();
+
+ // Truncate the (uncompressed, unencrypted) blob so its content is shorter than the file
+ // size recorded in the index, simulating a corrupted/incompletely-written blob.
+ var bytes = File.ReadAllBytes(blobFile);
+ File.WriteAllBytes(blobFile, bytes[..^2]);
+
+ Assert.Throws(() => SnapshotManager.LoadSnapshot(snapshot.Path, out _, out _));
+ }
+
[Fact]
public void LoadSnapshot_MissingBlob_ReturnsClearError()
{
diff --git a/tests/ManagedDrive.Tests/SpeedHistoryChartTests.cs b/tests/ManagedDrive.Tests/SpeedHistoryChartTests.cs
new file mode 100644
index 0000000..ff69d5d
--- /dev/null
+++ b/tests/ManagedDrive.Tests/SpeedHistoryChartTests.cs
@@ -0,0 +1,47 @@
+namespace ManagedDrive.Tests;
+
+public sealed class SpeedHistoryChartTests
+{
+ [Fact]
+ public void NormalizePoints_EmptyValues_ReturnsEmptyCollection()
+ {
+ var points = SpeedHistoryChart.NormalizePoints(Array.Empty(), width: 100, height: 50, scaleMax: 0);
+
+ Assert.Empty(points);
+ }
+
+ [Fact]
+ public void NormalizePoints_AllZero_ReturnsFlatLineAtBaseline()
+ {
+ var points = SpeedHistoryChart.NormalizePoints([0, 0, 0], width: 100, height: 50, scaleMax: 0);
+
+ Assert.All(points, p => Assert.Equal(50, p.Y));
+ }
+
+ [Fact]
+ public void NormalizePoints_MaxValue_TouchesTop()
+ {
+ var points = SpeedHistoryChart.NormalizePoints([0, 100], width: 100, height: 50, scaleMax: 100);
+
+ Assert.Equal(50, points[0].Y);
+ Assert.Equal(0, points[1].Y);
+ }
+
+ [Fact]
+ public void NormalizePoints_SinglePoint_DoesNotDivideByZeroWidth()
+ {
+ var points = SpeedHistoryChart.NormalizePoints([42], width: 100, height: 50, scaleMax: 42);
+
+ Assert.Single(points);
+ Assert.Equal(0, points[0].X);
+ }
+
+ [Fact]
+ public void NormalizePoints_SpansFullWidth()
+ {
+ var points = SpeedHistoryChart.NormalizePoints([0, 0, 0, 0], width: 90, height: 50, scaleMax: 0);
+
+ Assert.Equal(0, points[0].X);
+ Assert.Equal(90, points[3].X);
+ }
+}
diff --git a/tests/ManagedDrive.Tests/ThroughputTrackerTests.cs b/tests/ManagedDrive.Tests/ThroughputTrackerTests.cs
new file mode 100644
index 0000000..c2eb4d5
--- /dev/null
+++ b/tests/ManagedDrive.Tests/ThroughputTrackerTests.cs
@@ -0,0 +1,72 @@
+namespace ManagedDrive.Tests;
+
+public sealed class ThroughputTrackerTests
+{
+ private static readonly DateTimeOffset BaseTime = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ [Fact]
+ public void Sample_FirstCall_ReturnsZeroAndEstablishesBaseline()
+ {
+ var tracker = new ThroughputTracker();
+
+ var rate = tracker.Sample(1000, BaseTime);
+
+ Assert.Equal(0.0, rate);
+ }
+
+ [Fact]
+ public void Sample_SteadyGrowth_ReturnsExpectedBytesPerSecond()
+ {
+ var tracker = new ThroughputTracker();
+ tracker.Sample(0, BaseTime);
+
+ var rate = tracker.Sample(2000, BaseTime.AddSeconds(2));
+
+ Assert.Equal(1000.0, rate);
+ }
+
+ [Fact]
+ public void Sample_NoGrowth_ReturnsZero()
+ {
+ var tracker = new ThroughputTracker();
+ tracker.Sample(1000, BaseTime);
+
+ var rate = tracker.Sample(1000, BaseTime.AddSeconds(2));
+
+ Assert.Equal(0.0, rate);
+ }
+
+ [Fact]
+ public void Sample_NonMonotonicDecrease_ReturnsZeroNotNegative()
+ {
+ var tracker = new ThroughputTracker();
+ tracker.Sample(1000, BaseTime);
+
+ var rate = tracker.Sample(500, BaseTime.AddSeconds(2));
+
+ Assert.Equal(0.0, rate);
+ }
+
+ [Fact]
+ public void Sample_ZeroElapsedTime_ReturnsZeroNotInfinity()
+ {
+ var tracker = new ThroughputTracker();
+ tracker.Sample(1000, BaseTime);
+
+ var rate = tracker.Sample(2000, BaseTime);
+
+ Assert.Equal(0.0, rate);
+ }
+
+ [Fact]
+ public void Reset_ClearsBaseline_NextSampleReturnsZero()
+ {
+ var tracker = new ThroughputTracker();
+ tracker.Sample(1000, BaseTime);
+ tracker.Reset();
+
+ var rate = tracker.Sample(5000, BaseTime.AddSeconds(2));
+
+ Assert.Equal(0.0, rate);
+ }
+}